P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
check / check (push) Canceled after 0s

Book value is cost less what the books posted; the schedule's figure is
apart. An asset's acquired date comes from its entry. Contributed
property keeps its section 723 carryover basis apart from its depreciable
basis (the lower of value and adjusted basis), with its 704(c) built-in
gain and method. The K-1 worksheet reports tax-basis capital (Form 1065
Item L) beside the book figures, leaving open what only the CPA knows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:12 -06:00
1 parent 3a9aa92315
commit 2f999c6050
81 files changed
+9391 -2604

No files matched your search

+6
View File
@@ -55,6 +55,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
VENDOR_1099_DECISIONS,
MONTH_REPOSTING,
SCHEDULE_CONVENTION,
ASSET_BASIS,
];
/// Feature: `period.closed` closes to members with `"rounding":
@@ -106,6 +107,10 @@ pub const MONTH_REPOSTING: &str = "month_reposting";
/// months (P3 defect 9, plan/served.md D-11).
pub const SCHEDULE_CONVENTION: &str = "schedule_convention";
/// Feature: `asset.basis_set`, a new event type: an asset's basis facts
/// with the value at contribution and the §704(c) method (P3, defect 13).
pub const ASSET_BASIS: &str = "asset_basis";
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -117,6 +122,7 @@ pub const EVENT_TYPES: &[&str] = &[
"account.renumbered",
"account.tax_set",
"asset.acquisition_linked",
"asset.basis_set",
"asset.corrected",
"asset.depreciation_posted",
"asset.described",
+34 -9
View File
@@ -326,15 +326,18 @@ CREATE TABLE IF NOT EXISTS entry_approvals(
"#,
r#"
-- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and
-- 23; D-40). Each 1099 decision recorded with who decided (journal
-- feature vendor_1099_decisions), line by line: the date it takes effect
-- (NULL: from the beginning), nec, misc, not_reportable or NULL
-- (undetermined), and for not_reportable its kind and reason. A decision
-- from the beginning also sets vendors.form_1099, as every decision
-- written before these did. Payment methods (feature payment_instruments)
-- and the method each entry was paid with. Rows only come from lines
-- written under the features, so books written before them project the
-- same as before.
-- 23; D-40), and assets' basis facts with their depreciation runs keyed
-- by entry (plan P3, defect 13; D-41). One migration: none of P3 had
-- shipped when they met.
--
-- Each 1099 decision recorded with who decided (journal feature
-- vendor_1099_decisions), line by line: the date it takes effect (NULL:
-- from the beginning), nec, misc, not_reportable or NULL (undetermined),
-- and for not_reportable its kind and reason. A decision from the
-- beginning also sets vendors.form_1099, as every decision written before
-- these did. Payment methods (feature payment_instruments) and the method
-- each entry was paid with. Rows only come from lines written under the
-- features, so books written before them project the same as before.
CREATE TABLE IF NOT EXISTS vendor_1099_decisions(
seq INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL,
@@ -356,5 +359,27 @@ CREATE TABLE IF NOT EXISTS entry_instruments(
entry_id INTEGER PRIMARY KEY,
instrument_id INTEGER NOT NULL
);
--
-- An asset's basis facts beyond the rule and contributor basis
-- (asset.basis_set): the fair market value at contribution (TEXT, exact
-- decimal), the §704(c) method, and who gave the facts (JSON). tax_rule
-- keeps the rule as journaled, by its pre-P3 name or its wire name.
-- Depreciation runs keyed by their entry too: the journal can hold a
-- second run for a month whose first was reversed, and the projection
-- keeps every run, as replay does.
ALTER TABLE assets ADD COLUMN fmv_at_contribution TEXT;
ALTER TABLE assets ADD COLUMN sec704c_method TEXT;
ALTER TABLE assets ADD COLUMN basis_source_json TEXT;
CREATE TABLE asset_depreciation_v13(
asset_id INTEGER NOT NULL,
period TEXT NOT NULL,
entry_id INTEGER NOT NULL,
amount TEXT NOT NULL,
PRIMARY KEY (asset_id, period, entry_id)
);
INSERT INTO asset_depreciation_v13(asset_id, period, entry_id, amount)
SELECT asset_id, period, entry_id, amount FROM asset_depreciation;
DROP TABLE asset_depreciation;
ALTER TABLE asset_depreciation_v13 RENAME TO asset_depreciation;
"#,
];
+311 -162
View File
@@ -277,8 +277,9 @@ pub struct DisposalPreview {
pub period: String,
}
/// A depreciation run the books carry for an asset: its row in the
/// projection, its entry in effect (not reversed).
/// A depreciation run the books carry for an asset: one the ledger
/// recorded (`Ledger::depreciation_runs`), its entry in effect (not
/// reversed).
#[derive(Clone, Debug, PartialEq, Eq)]
struct LiveRun {
/// Its month, "YYYY-MM" as journaled.
@@ -1489,7 +1490,7 @@ impl Store {
}
// Reversing `entry` re-instates the run, adding it back in the
// reversal's month.
if let Some((now, _)) = self.depreciation_posted_entry(asset_id, period)?
if let Some((now, _)) = self.depreciation_posted_entry(asset_id, period)
&& now != top
{
return refuse(format!(
@@ -1504,7 +1505,7 @@ impl Store {
asset,
date.year(),
date.month(),
&self.depreciation_effects(asset_id)?,
&self.depreciation_effects(asset_id),
)?
.amount;
if amount > room {
@@ -1534,7 +1535,8 @@ impl Store {
json!({
"id": id,
"description": new.description,
"purchase_date": new.purchase_date.to_string(),
// The in-service date, as every asset.registered names it.
"purchase_date": new.in_service_date.to_string(),
"cost": { "amount": new.cost.amount.to_string(), "currency": new.cost.currency.code() },
"salvage_value": { "amount": new.salvage_value.amount.to_string(), "currency": new.salvage_value.currency.code() },
"useful_life_months": new.useful_life_months,
@@ -1738,8 +1740,9 @@ impl Store {
}
/// Correct a registered asset's cost, salvage, life, or in-service date
/// (see `Ledger::correct_asset`), with the reason. Refused once any
/// depreciation for it is posted. Journaled as `asset.corrected`, with
/// (see `Ledger::correct_asset`), with the reason. Refused while the
/// books carry any depreciation for it (a run posted and not reversed:
/// `Ledger::accumulated_posted`). Journaled as `asset.corrected`, with
/// the figures it had.
pub fn correct_asset(
&mut self,
@@ -1751,7 +1754,11 @@ impl Store {
return Err(StoreError::ReasonRequired);
}
self.ledger.check_correct_asset(id, &fix)?;
if !self.asset_depreciation_total(id)?.is_zero() {
if !self
.ledger
.accumulated_posted(id, chrono::NaiveDate::MAX)?
.is_zero()
{
return Err(StoreError::AssetDepreciationPosted(id));
}
let a = self.ledger.get_asset(id).expect("checked").clone();
@@ -1764,13 +1771,14 @@ impl Store {
"cost": money(&fix.cost),
"salvage_value": money(&fix.salvage_value),
"useful_life_months": fix.useful_life_months,
"purchase_date": fix.purchase_date.to_string(),
// The in-service date, as every asset.corrected names it.
"purchase_date": fix.in_service_date.to_string(),
"reason": reason,
"previous": {
"cost": money(&a.cost),
"salvage_value": money(&a.salvage_value),
"useful_life_months": a.useful_life_months,
"purchase_date": a.purchase_date.to_string(),
"purchase_date": a.in_service_date.to_string(),
},
}),
)?;
@@ -1779,23 +1787,46 @@ impl Store {
Ok(())
}
/// Record an asset's federal tax basis (see
/// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`.
pub fn set_asset_tax_basis(
/// Record how the company got an asset and the facts its tax figures
/// follow from (see `Ledger::set_asset_basis`). Journaled as
/// `asset.basis_set {asset_id, rule, contributor_adjusted_basis,
/// fmv_at_contribution, sec704c_method, source, tax_basis_723,
/// depreciable_basis, built_in_gain_704c}`: the facts, and for the
/// line's readers the figures as derived when written (null for a
/// purchase's depreciable basis: the cost, whatever it becomes).
/// Replay reads the facts and never recomputes the figures (rules.md
/// B-28). It needs the journal feature `asset_basis`: refused,
/// journaling nothing, in books that haven't declared it (books an
/// older version wrote, until their format upgrade: B-27).
pub fn set_asset_basis(
&mut self,
id: AssetId,
tax: ledger_core::TaxBasis,
basis: ledger_core::AssetBasis,
) -> Result<(), StoreError> {
self.ledger.check_asset_tax_basis(id, tax)?;
self.ledger.check_asset_basis(id, &basis)?;
let mut asset = self.ledger.get_asset(id).expect("checked").clone();
asset.basis = Some(basis.clone());
let figures = asset.basis_figures()?.expect("a checked basis has figures");
let dec = |d: Option<Decimal>| d.map(|d| d.to_string());
self.ensure_feature(crate::format::ASSET_BASIS)?;
let line = self.append_journal(
"asset.tax_basis_set",
"asset.basis_set",
json!({
"asset_id": id,
"rule": tax.rule.key(),
"contributor_basis": tax.contributor_basis.map(|b| b.to_string()),
"rule": basis.rule.key(),
"contributor_adjusted_basis": dec(basis.contributor_adjusted_basis),
"fmv_at_contribution": dec(basis.fmv_at_contribution),
"sec704c_method": basis.sec704c_method.map(|m| m.key()),
"source": basis.source,
"tax_basis_723": dec(figures.tax_basis_723),
"depreciable_basis": dec(basis
.rule
.is_contribution()
.then_some(figures.depreciable_basis)),
"built_in_gain_704c": dec(figures.built_in_gain_704c),
}),
)?;
self.ledger.set_asset_tax_basis(id, tax)?;
self.ledger.set_asset_basis(id, basis)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
@@ -1854,114 +1885,48 @@ impl Store {
}
/// The depreciation runs the books carry for an asset: every run
/// journaled for it ([`Self::depreciation_runs_ever`]) whose entry is in
/// effect, in journal order. A reversed run is left out, so its month is
/// due again (P3 depreciation review R3); a reversed reversal counts
/// again, even after its month was run again and that run reversed (the
/// projection's row for the month names the latest run only, so it is
/// not read here: P3 review round 2).
fn live_runs(&self, asset_id: AssetId) -> Result<Vec<LiveRun>, StoreError> {
Ok(self
.depreciation_runs_ever(asset_id)?
.into_iter()
.filter(|(entry_id, _, _)| self.ledger.in_effect(*entry_id))
.map(|(entry_id, amount, period)| LiveRun {
period,
entry_id,
amount,
/// recorded for it (`asset.depreciation_posted`, its disposal's final
/// depreciation included: [`Ledger::depreciation_runs`]) whose entry is
/// in effect, in journal order. A reversed run is left out, so its
/// month is due again (P3 depreciation review R3); a reversed reversal
/// counts again, even after its month was run again and that run
/// reversed (P3 review round 2).
fn live_runs(&self, asset_id: AssetId) -> Vec<LiveRun> {
self.ledger
.depreciation_runs(asset_id)
.iter()
.filter(|r| self.ledger.in_effect(r.entry))
.map(|r| LiveRun {
period: r.period.clone(),
entry_id: r.entry,
amount: r.amount,
})
.collect())
.collect()
}
/// The run the books carry for this asset and period, if any: (entry,
/// amount).
fn depreciation_posted_entry(
&self,
asset_id: AssetId,
period: &str,
) -> Result<Option<(u64, Decimal)>, StoreError> {
Ok(self
.live_runs(asset_id)?
.into_iter()
.find(|r| r.period == period)
.map(|r| (r.entry_id, r.amount)))
/// amount) ([`Ledger::live_depreciation_run`]).
fn depreciation_posted_entry(&self, asset_id: AssetId, period: &str) -> Option<(u64, Decimal)> {
self.ledger
.live_depreciation_run(asset_id, period)
.map(|r| (r.entry, r.amount))
}
/// Whether this asset's period was ever run, reversed or not: running
/// it again writes a second `asset.depreciation_posted` for it, which an
/// older version would ignore (feature `month_reposting`).
fn depreciation_period_was_run(
&self,
asset_id: AssetId,
period: &str,
) -> Result<bool, StoreError> {
Ok(self
.conn
.query_row(
"SELECT 1 FROM asset_depreciation WHERE asset_id = ? AND period = ?",
params![asset_id as i64, period],
|_| Ok(()),
)
.optional()?
.is_some())
}
/// Every depreciation run journaled for an asset, reversed or not, in
/// journal order: each `asset.depreciation_posted` (its runs, and its
/// disposal's final depreciation) as (entry, amount, period). The
/// projection keeps a period's latest run only; the journal keeps each.
fn depreciation_runs_ever(
&self,
asset_id: AssetId,
) -> Result<Vec<(EntryId, Decimal, String)>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT payload FROM events WHERE type = 'asset.depreciation_posted' ORDER BY seq",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
let p: Value = serde_json::from_str(&row?)?;
if get_u64(&p, "asset_id")? != asset_id {
continue;
}
let amount = get_str(&p, "amount")?;
let amount = amount
.parse()
.map_err(|_| StoreError::JournalCorrupt(format!("bad amount {amount}")))?;
out.push((
get_u64(&p, "entry_id")?,
amount,
get_str(&p, "period")?.to_string(),
));
}
Ok(out)
fn depreciation_period_was_run(&self, asset_id: AssetId, period: &str) -> bool {
self.ledger
.depreciation_runs(asset_id)
.iter()
.any(|r| r.period == period)
}
/// What the books carry for an asset's depreciation, entry by entry,
/// each in its own month: every run and every reversal of one
/// (ledger_core::depreciation_effects), as the catch-up rule reads it.
fn depreciation_effects(
&self,
asset_id: AssetId,
) -> Result<Vec<ledger_core::PostedRun>, StoreError> {
let runs: Vec<(EntryId, Decimal)> = self
.depreciation_runs_ever(asset_id)?
.into_iter()
.map(|(e, a, _)| (e, a))
.collect();
Ok(ledger_core::depreciation_effects(&self.ledger, &runs))
}
/// Sum of the depreciation the books carry for an asset (its runs in
/// effect). Summed in Rust with exact Decimal math: amounts are TEXT in
/// SQLite, so SQLite must not sum them (REAL would lose pennies).
fn asset_depreciation_total(&self, asset_id: AssetId) -> Result<Decimal, StoreError> {
self.live_runs(asset_id)?
.iter()
.try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount))
.ok_or_else(|| {
StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large"))
})
fn depreciation_effects(&self, asset_id: AssetId) -> Vec<ledger_core::PostedRun> {
self.ledger.depreciation_effects_of(asset_id)
}
/// Post one month's depreciation for one asset: what is due through the
@@ -2018,7 +1983,7 @@ impl Store {
.find(|l| l.period == period)
.map_or(Decimal::ZERO, |l| l.amount))
};
if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period)? {
if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period) {
return Ok(DepreciationRun {
asset_id,
period: period.to_string(),
@@ -2033,7 +1998,7 @@ impl Store {
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
}
let due =
ledger_core::run_amount(&asset, year, month, &self.depreciation_effects(asset_id)?)?;
ledger_core::run_amount(&asset, year, month, &self.depreciation_effects(asset_id))?;
if due.amount.is_zero() {
let last = ledger_core::life_end(&asset)?
.pred_opt()
@@ -2066,16 +2031,18 @@ impl Store {
let description = format!("Depreciation — {} — {period}", asset.description);
let money = Money::new(amount, self.base_currency);
// Every refusal before the first journaled line: the entry on a
// copy, then the journal feature a period run again needs. A
// refusal after the entry posted would leave it without the line
// that makes it the period's run.
// copy (and that it is the period's run, no more and no less:
// Ledger::check_depreciation_run), then the journal feature a
// period run again needs. A refusal after the entry posted would
// leave it without the line that makes it the period's run.
self.rehearse(|l| {
let e = l.try_create_entry(period_end, &description)?;
l.add_line(e, asset.expense_account, Side::Debit, money)?;
l.add_line(e, asset.accum_depr_account, Side::Credit, money)?;
l.post_entry(e)
l.post_entry(e)?;
l.check_depreciation_run(asset_id, period, e, amount)
})?;
if self.depreciation_period_was_run(asset_id, period)? {
if self.depreciation_period_was_run(asset_id, period) {
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
}
let entry_id = self.create_entry(period_end, &description)?;
@@ -2091,6 +2058,8 @@ impl Store {
"amount": amount.to_string(),
}),
)?;
self.ledger
.record_depreciation_run(asset_id, period, entry_id, amount)?;
apply_line_to_sqlite(&self.conn, &dline)?;
Ok(DepreciationRun {
asset_id,
@@ -2202,8 +2171,9 @@ impl Store {
let month = (date.year(), date.month());
let month_end = ledger_core::schedules::month_end(date);
let (mut later, mut late) = (Vec::new(), Vec::new());
for (run, amount, period) in self.depreciation_runs_ever(asset_id)? {
if period_month(&period) > month && self.ledger.in_effect(run) {
for r in self.ledger.depreciation_runs(asset_id) {
let (run, amount, period) = (r.entry, r.amount, &r.period);
if period_month(period) > month && self.ledger.in_effect(run) {
later.push(format!("{period} (entry {run})"));
continue;
}
@@ -2321,19 +2291,27 @@ impl Store {
}
.into());
}
if date < asset.purchase_date {
return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into());
let acquired = self
.ledger
.asset_acquired_date(asset_id)
.expect("known asset");
if date < acquired {
return Err(LedgerError::DisposalBeforeAcquisition {
date: date.to_string(),
acquired: acquired.to_string(),
}
.into());
}
let period = date.format("%Y-%m").to_string();
self.check_nothing_after_disposal(asset_id, date)?;
let runs = self.live_runs(asset_id)?;
let runs = self.live_runs(asset_id);
let too_large =
|| StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large"));
let posted = runs
.iter()
.try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount))
.ok_or_else(too_large)?;
let target = ledger_core::accumulated_depreciation(asset, date)?;
let target = ledger_core::accumulated_scheduled_through(asset, date)?;
let (up, down) = ledger_core::kernel::true_up(target, posted).ok_or_else(too_large)?;
let in_month = runs.iter().any(|r| r.period == period);
let (final_depreciation, catch_up_depreciation) = if in_month {
@@ -2449,8 +2427,16 @@ impl Store {
if asset.status != AssetStatus::Active {
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
}
if date < asset.purchase_date {
return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into());
let acquired = self
.ledger
.asset_acquired_date(asset_id)
.expect("known asset");
if date < acquired {
return Err(LedgerError::DisposalBeforeAcquisition {
date: date.to_string(),
acquired: acquired.to_string(),
}
.into());
}
let base = self.base_currency;
@@ -2488,14 +2474,24 @@ impl Store {
),
);
// Several commands make one disposal: rehearse them all together,
// so a refusal anywhere leaves nothing journaled.
// so a refusal anywhere leaves nothing journaled (the final
// depreciation's entry checked as the period's run, no more and no
// less: Ledger::check_depreciation_run).
self.rehearse(|l| {
for (description, lines) in depreciation.iter().chain([&disposal]) {
for (i, (description, lines)) in depreciation.iter().chain([&disposal]).enumerate() {
let e = l.try_create_entry(date, description)?;
for (account, side, amount) in lines {
l.add_line(e, *account, *side, Money::new(*amount, base))?;
}
l.post_entry(e)?;
if i == 0 && depreciation.is_some() {
l.check_depreciation_run(
asset_id,
&preview.period,
e,
preview.final_depreciation,
)?;
}
}
l.dispose_asset(asset_id, date, proceeds)
})?;
@@ -2518,7 +2514,7 @@ impl Store {
// depreciation would be in the disposal entry): its line again,
// which needs a journal feature, asked for (and refused in books
// that haven't declared it) before the first line is journaled.
if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period)? {
if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period) {
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
}
if let Some((description, lines)) = &depreciation {
@@ -2536,6 +2532,12 @@ impl Store {
"amount": preview.final_depreciation.to_string(),
}),
)?;
self.ledger.record_depreciation_run(
asset_id,
&preview.period,
eid,
preview.final_depreciation,
)?;
apply_line_to_sqlite(&self.conn, &dline)?;
}
let entry_id = self.create_entry(date, &disposal.0)?;
@@ -4741,7 +4743,11 @@ impl Store {
// The live ledger equals the replay (checked above, in full); the
// SQLite read-back's asset register, classes, and payroll runs
// must equal it too (the accounting snapshot ignores them).
let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::<Vec<_>>();
let assets_of = |l: &Ledger| {
l.registered_assets()
.map(|a| (a.clone(), l.depreciation_runs(a.id).to_vec()))
.collect::<Vec<_>>()
};
if assets_of(&replayed) != assets_of(&c) {
return Err(StoreError::VerifyMismatch(
"asset register diverged between journal, live ledger, and sqlite projection"
@@ -5058,7 +5064,7 @@ impl Store {
};
let got = ledger.replay_register_asset(NewAsset {
description,
purchase_date: parse_date(&purchase_date)?,
in_service_date: parse_date(&purchase_date)?,
cost: parse_amt(&cost_amount, &cost_currency)?,
salvage_value: parse_amt(&salvage_amount, &salvage_currency)?,
useful_life_months: life_months as u32,
@@ -5103,20 +5109,56 @@ impl Store {
let (id, serial) = row?;
ledger.set_asset_serial(id as u64, Some(&serial))?;
}
// Basis records: a pre-P3 one (no value at contribution) as its
// event replays, a newer one with its facts.
let mut xstmt = self.conn.prepare(
"SELECT id, tax_rule, contributor_basis FROM assets WHERE tax_rule IS NOT NULL ORDER BY id",
"SELECT id, tax_rule, contributor_basis, fmv_at_contribution, sec704c_method, basis_source_json FROM assets WHERE tax_rule IS NOT NULL ORDER BY id",
)?;
let taxes = xstmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, Option<String>>(5)?,
))
})?;
for row in taxes {
let (id, rule, basis) = row?;
let tax = tax_basis_from_journal(&json!({"rule": rule, "contributor_basis": basis}))?;
ledger.replay_set_asset_tax_basis(id as u64, tax)?;
let (id, rule, basis, fmv, method, source) = row?;
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("assets.{what}"));
let dec = |v: Option<String>, what: &str| {
v.map(|s| s.parse::<Decimal>().map_err(|_| corrupt(what)))
.transpose()
};
let fmv = dec(fmv, "fmv_at_contribution")?;
if fmv.is_none()
&& method.is_none()
&& source.is_none()
&& let Some(legacy) = ledger_core::BasisRule::parse_legacy(&rule)
{
ledger.replay_set_asset_tax_basis(
id as u64,
legacy,
dec(basis, "contributor_basis")?,
)?;
continue;
}
let basis = ledger_core::AssetBasis {
rule: ledger_core::BasisRule::parse(&rule).ok_or_else(|| corrupt("tax_rule"))?,
contributor_adjusted_basis: dec(basis, "contributor_basis")?,
fmv_at_contribution: fmv,
sec704c_method: method
.map(|m| {
ledger_core::Sec704cMethod::parse(&m)
.ok_or_else(|| corrupt("sec704c_method"))
})
.transpose()?,
source: source
.map(|j| serde_json::from_str(&j).map_err(|_| corrupt("basis_source_json")))
.transpose()?,
};
ledger.replay_set_asset_basis(id as u64, basis)?;
}
let mut qstmt = self.conn.prepare(
"SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id",
@@ -5126,6 +5168,26 @@ impl Store {
let (id, entry) = row?;
ledger.restore_asset_acquisition(id as u64, entry as u64)?;
}
// Depreciation runs, each asset's in the order they were posted
// (entry order, as replay records them).
let mut dstmt = self.conn.prepare(
"SELECT asset_id, period, entry_id, amount FROM asset_depreciation ORDER BY asset_id, entry_id",
)?;
let runs = dstmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
))
})?;
for row in runs {
let (asset, period, entry, amount) = row?;
let amount = amount.parse::<Decimal>().map_err(|_| {
StoreError::JournalCorrupt(format!("asset_depreciation amount {amount}"))
})?;
ledger.record_depreciation_run(asset as u64, &period, entry as u64, amount)?;
}
// Payroll runs: id order; lines in insertion order. Amounts are
// TEXT in SQLite, so they parse here with exact Decimal math.
let parse_payroll_amount = |s: String| {
@@ -5725,7 +5787,8 @@ pub(crate) fn replay_line_into_ledger(
let want = get_u64(p, "id")?;
let got = ledger.replay_register_asset(NewAsset {
description: get_str(p, "description")?.to_string(),
purchase_date: parse_date(get_str(p, "purchase_date")?)?,
// `purchase_date` has always meant the in-service date.
in_service_date: parse_date(get_str(p, "purchase_date")?)?,
cost: parse_money(p, "cost")?,
salvage_value: parse_money(p, "salvage_value")?,
useful_life_months: get_u64(p, "useful_life_months")? as u32,
@@ -5824,19 +5887,24 @@ pub(crate) fn replay_line_into_ledger(
cost: parse_money(p, "cost")?,
salvage_value: parse_money(p, "salvage_value")?,
useful_life_months: get_u64(p, "useful_life_months")? as u32,
purchase_date: parse_date(get_str(p, "purchase_date")?)?,
in_service_date: parse_date(get_str(p, "purchase_date")?)?,
},
)?;
}
"asset.tax_basis_set" => {
let tax = tax_basis_from_journal(p)?;
ledger.replay_set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?;
let (rule, contributor_basis) = tax_basis_from_journal(p)?;
ledger.replay_set_asset_tax_basis(get_u64(p, "asset_id")?, rule, contributor_basis)?;
}
"asset.basis_set" => {
let basis = asset_basis_from_journal(line, p)?;
ledger.replay_set_asset_basis(get_u64(p, "asset_id")?, basis)?;
}
"asset.described" => {
ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?;
}
"asset.acquisition_linked" => {
ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?;
ledger
.replay_set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?;
}
"asset.serial_set" => {
let serial =
@@ -5850,7 +5918,19 @@ pub(crate) fn replay_line_into_ledger(
}
"asset.depreciation_posted" => {
// The entry itself was journaled as entry.created/entry.posted;
// this line is idempotency bookkeeping for the projection.
// this line says it was the asset's run for the month, which is
// what the books carry for the asset (Ledger::accumulated_posted).
// Replay checks only that it is well formed and names an asset
// and entry that exist (rules.md B-28).
let amount = get_str(p, "amount")?.parse::<Decimal>().map_err(|_| {
StoreError::JournalCorrupt("asset.depreciation_posted amount".into())
})?;
ledger.record_depreciation_run(
get_u64(p, "asset_id")?,
get_str(p, "period")?,
get_u64(p, "entry_id")?,
amount,
)?;
}
"asset.disposed" => {
let id = get_u64(p, "asset_id")?;
@@ -6143,21 +6223,69 @@ fn apply_document_payment(
/// written before targets existed).
/// An `asset.tax_basis_set` payload's rule and contributor basis, strictly:
/// an unknown rule or a basis that isn't a decimal string is corrupt.
fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError> {
/// A pre-P3 `asset.tax_basis_set`: the rule by its pre-P3 name (`cost`,
/// `carryover`, `personal_use_conversion`) and the contributor basis.
fn tax_basis_from_journal(
p: &Value,
) -> Result<(ledger_core::BasisRule, Option<Decimal>), StoreError> {
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.tax_basis_set {what}"));
let rule =
ledger_core::TaxBasisRule::parse(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?;
let contributor_basis = match p.get("contributor_basis") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(|s| s.parse::<Decimal>().ok())
.ok_or_else(|| corrupt("contributor_basis"))?,
),
};
Ok(ledger_core::TaxBasis {
ledger_core::BasisRule::parse_legacy(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?;
Ok((
rule,
contributor_basis,
optional_decimal(p, "contributor_basis").map_err(|_| corrupt("contributor_basis"))?,
))
}
/// An optional decimal string: absent or null is None; anything else must
/// parse.
fn optional_decimal(p: &Value, key: &str) -> Result<Option<Decimal>, ()> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_str()
.and_then(|s| s.parse::<Decimal>().ok())
.map(Some)
.ok_or(()),
}
}
/// An `asset.basis_set`'s facts. A rule or §704(c) method this version
/// doesn't know was written by a newer one: NewerJournal, never corrupt
/// (rules.md B-27). The figures the line carries are type-checked and
/// otherwise left to its readers: replay never recomputes them (B-28).
fn asset_basis_from_journal(
line: &JournalLine,
p: &Value,
) -> Result<ledger_core::AssetBasis, StoreError> {
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.basis_set {what}"));
let key = get_str(p, "rule")?;
let rule = ledger_core::BasisRule::parse(key)
.ok_or_else(|| crate::format::newer_content(line, format!("the basis rule {key:?}")))?;
let sec704c_method = match p.get("sec704c_method") {
None | Some(Value::Null) => None,
Some(v) => {
let key = v.as_str().ok_or_else(|| corrupt("sec704c_method"))?;
Some(ledger_core::Sec704cMethod::parse(key).ok_or_else(|| {
crate::format::newer_content(line, format!("the §704(c) method {key:?}"))
})?)
}
};
let source = match p.get("source") {
None | Some(Value::Null) => None,
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| corrupt("source"))?),
};
for figure in ["tax_basis_723", "depreciable_basis", "built_in_gain_704c"] {
optional_decimal(p, figure).map_err(|_| corrupt(figure))?;
}
Ok(ledger_core::AssetBasis {
rule,
contributor_adjusted_basis: optional_decimal(p, "contributor_adjusted_basis")
.map_err(|_| corrupt("contributor_adjusted_basis"))?,
fmv_at_contribution: optional_decimal(p, "fmv_at_contribution")
.map_err(|_| corrupt("fmv_at_contribution"))?,
sec704c_method,
source,
})
}
@@ -6544,8 +6672,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?;
}
"asset.tax_basis_set" => {
// A pre-P3 record: its rule by its pre-P3 name, no value at
// contribution, method or source.
conn.execute(
"UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?",
"UPDATE assets SET tax_rule = ?, contributor_basis = ?, fmv_at_contribution = NULL, sec704c_method = NULL, basis_source_json = NULL WHERE id = ?",
params![
get_str(p, "rule")?,
p.get("contributor_basis").and_then(|v| v.as_str()),
@@ -6553,6 +6683,26 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
"asset.basis_set" => {
// The facts, the rule by its wire name (tax_rule holds either
// set: the names differ but for personal_use_conversion, which
// means the same in both).
let source = match p.get("source") {
None | Some(Value::Null) => None,
Some(v) => Some(v.to_string()),
};
conn.execute(
"UPDATE assets SET tax_rule = ?, contributor_basis = ?, fmv_at_contribution = ?, sec704c_method = ?, basis_source_json = ? WHERE id = ?",
params![
get_str(p, "rule")?,
p.get("contributor_adjusted_basis").and_then(|v| v.as_str()),
p.get("fmv_at_contribution").and_then(|v| v.as_str()),
p.get("sec704c_method").and_then(|v| v.as_str()),
source,
get_u64(p, "asset_id")? as i64
],
)?;
}
"asset.described" => {
conn.execute(
"UPDATE assets SET description = ? WHERE id = ?",
@@ -6766,13 +6916,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?;
}
"asset.depreciation_posted" => {
// A period run again after its run was reversed takes the row
// (feature month_reposting); a period whose run stands keeps
// it, as before.
// Every run, a period run again after its run was reversed
// (feature month_reposting) beside the first: keyed by its
// entry too (schema v13), as the ledger records them and the
// projection's read-back restores them.
conn.execute(
"INSERT INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?) \
ON CONFLICT(asset_id, period) DO UPDATE SET entry_id = excluded.entry_id, amount = excluded.amount \
WHERE EXISTS (SELECT 1 FROM entries r WHERE r.reversal_of = asset_depreciation.entry_id)",
"INSERT OR IGNORE INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?)",
params![
get_u64(p, "asset_id")? as i64,
get_str(p, "period")?,
+3 -3
View File
@@ -111,7 +111,7 @@ fn books(dir: &Path) -> u64 {
}
s.register_asset(NewAsset {
description: "Asset".into(),
purchase_date: day(2026, 1, 16),
in_service_date: day(2026, 1, 16),
cost: usd(300_000),
salvage_value: usd(0),
useful_life_months: 3,
@@ -219,7 +219,7 @@ fn check(store: &Store, asset: u64, ops: &[Op], verify: bool) {
let a = store.ledger().get_asset(asset).unwrap().clone();
let mut active = a.clone();
active.status = AssetStatus::Active;
let sched = |on: NaiveDate| ledger_core::accumulated_depreciation(&active, on).unwrap();
let sched = |on: NaiveDate| ledger_core::accumulated_scheduled_through(&active, on).unwrap();
// INVARIANT 3: one run in effect per month.
let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap();
let mut live: Vec<String> = Vec::new();
@@ -294,7 +294,7 @@ fn check_completion(dir: &Path, asset: u64, ops: &[Op]) {
for e in ends() {
let x = balance(&store, EXPENSE, e);
assert!(
x <= ledger_core::accumulated_depreciation(&a, e).unwrap(),
x <= ledger_core::accumulated_scheduled_through(&a, e).unwrap(),
"{ops:?} then every month: {x} on {e}"
);
}
+393 -83
View File
@@ -4,10 +4,12 @@
//! SQLite projection agreeing with the journal (`Store::verify`), books
//! written before tags existed, refusals journaling nothing, and corrupt
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
//! v10 schema, corrupt payloads). A database at an older schema under this
//! version's file name is rebuilt, never migrated (each schema keeps a file
//! of its own: rules.md B-27, tests/schema_files.rs).
//! `asset.basis_set`, pre-P3 `asset.tax_basis_set` {asset_id, rule,
//! contributor_basis | null} replayed, the v10 schema and v13's, corrupt
//! and newer payloads). A database at an older schema under this
//! version's file name is rebuilt, never migrated, and P2's own file is
//! left as it is (each schema keeps a file of its own: rules.md B-27,
//! tests/schema_files.rs).
//!
//! Expectations come from the doc comments on `Store::set_asset_tag`,
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
@@ -90,7 +92,7 @@ fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId {
store
.register_asset(NewAsset {
description: name.to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
@@ -571,98 +573,123 @@ fn a_description_change_replays_and_verifies() {
// ------------------------------------------------- tax basis (rules.md B-25)
//
// Expectations from `Store::set_asset_tax_basis` (journaled as
// `asset.tax_basis_set`), `Ledger::set_asset_tax_basis` INVARIANTS and
// SCENARIOS, and the schema v10 migration comment (tax_rule and
// contributor_basis as TEXT, exact decimal).
// Expectations from `Store::set_asset_basis` (journaled as
// `asset.basis_set`, the feature `asset_basis` declared first),
// `Ledger::set_asset_basis` INVARIANTS and SCENARIOS, the replay of
// pre-P3 `asset.tax_basis_set` lines, and the schema v10 and v13
// migration comments (tax_rule, contributor_basis, fmv_at_contribution as
// TEXT, exact decimal).
fn tax(rule: ledger_core::TaxBasisRule, basis: Option<&str>) -> ledger_core::TaxBasis {
ledger_core::TaxBasis {
fn dec(s: &str) -> rust_decimal::Decimal {
s.parse().unwrap()
}
fn contribution(
rule: ledger_core::BasisRule,
adjusted: &str,
fmv: &str,
) -> ledger_core::AssetBasis {
ledger_core::AssetBasis::contribution(rule, dec(adjusted), dec(fmv))
}
/// A pre-P3 record, as `asset.tax_basis_set` replays it.
fn legacy(rule: ledger_core::BasisRule, basis: Option<&str>) -> ledger_core::AssetBasis {
ledger_core::AssetBasis {
rule,
contributor_basis: basis.map(|b| b.parse().unwrap()),
contributor_adjusted_basis: basis.map(dec),
fmv_at_contribution: None,
sec704c_method: None,
source: None,
}
}
fn tax_of(s: &Store, id: AssetId) -> Option<ledger_core::TaxBasis> {
s.ledger().get_asset(id).unwrap().tax
fn basis_of(s: &Store, id: AssetId) -> Option<ledger_core::AssetBasis> {
s.ledger().get_asset(id).unwrap().basis.clone()
}
/// Journaled as `asset.tax_basis_set` {asset_id, rule, contributor_basis}:
/// the basis a decimal string, or null for a purchase.
/// Journaled as `asset.basis_set` {asset_id, rule, contributor_adjusted_basis,
/// fmv_at_contribution, sec704c_method, source, tax_basis_723,
/// depreciable_basis, built_in_gain_704c}: the facts, and the figures for
/// the line's readers (a purchase's depreciable basis null: the cost,
/// whatever it becomes). The first declares the feature asset_basis.
#[test]
fn tax_basis_journals_asset_tax_basis_set() {
use ledger_core::TaxBasisRule::*;
fn a_basis_journals_asset_basis_set() {
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, y) = fixture("journal-tax");
s.set_asset_tax_basis(x, tax(Cost, None)).unwrap();
s.set_asset_tax_basis(y, tax(PersonalUseConversion, Some("1500.25")))
.unwrap();
s.set_asset_tax_basis(x, tax(Carryover, Some("800.00")))
s.set_asset_basis(x, ledger_core::AssetBasis::purchase())
.unwrap();
let mut b = contribution(PersonalUseConversion, "1500.25", "1200.00");
b.sec704c_method = Some(ledger_core::Sec704cMethod::Traditional);
s.set_asset_basis(y, b.clone()).unwrap();
let j = journal(&dir);
let with_feature = |p: &Value| {
p["features"]
.as_array()
.is_some_and(|f| f.contains(&json!("asset_basis")))
};
let declared: Vec<usize> = j
.iter()
.enumerate()
.filter(|(_, (t, p))| t == "journal.format_set" && with_feature(p))
.map(|(i, _)| i)
.collect();
let first = j.iter().position(|(t, _)| t == "asset.basis_set").unwrap();
assert_eq!(declared.len(), 1, "declared once");
assert!(declared[0] < first, "before the first asset.basis_set");
let set: Vec<&Value> = j
.iter()
.filter(|(t, _)| t == "asset.tax_basis_set")
.filter(|(t, _)| t == "asset.basis_set")
.map(|(_, p)| p)
.collect();
assert_eq!(set.len(), 3);
assert_eq!(set.len(), 2);
assert_eq!(
set[0],
&json!({"asset_id": x, "rule": "cost", "contributor_basis": null})
&json!({"asset_id": x, "rule": "purchase", "contributor_adjusted_basis": null,
"fmv_at_contribution": null, "sec704c_method": null, "source": null,
"tax_basis_723": null, "depreciable_basis": null, "built_in_gain_704c": null})
);
assert_eq!(set[1]["asset_id"], json!(y));
assert_eq!(set[1]["rule"], "personal_use_conversion");
let b = set[1]["contributor_basis"]
.as_str()
.expect("a decimal string");
assert_eq!(
b.parse::<rust_decimal::Decimal>().unwrap(),
"1500.25".parse().unwrap()
);
assert_eq!(set[2]["rule"], "carryover");
let b = set[2]["contributor_basis"]
.as_str()
.expect("a decimal string");
assert_eq!(
b.parse::<rust_decimal::Decimal>().unwrap(),
"800.00".parse().unwrap()
);
// Set again replaces (the journal keeps both).
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("800.00"))));
assert_eq!(
s.ledger().get_asset(y).unwrap().tax_basis(),
Some("1200.00".parse().unwrap()),
"lesser of the 1200.00 book cost and the 1500.25 basis"
set[1],
&json!({"asset_id": y, "rule": "personal_use_conversion",
"contributor_adjusted_basis": "1500.25", "fmv_at_contribution": "1200.00",
"sec704c_method": "traditional", "source": null,
"tax_basis_723": "1500.25", "depreciable_basis": "1200.00",
"built_in_gain_704c": "-300.25"})
);
assert_eq!(basis_of(&s, y), Some(b));
s.verify().unwrap();
}
/// A refused tax basis (Ledger::set_asset_tax_basis INVARIANT 3) journals
/// nothing and changes nothing, now or on reopen.
/// A refused basis (Ledger::set_asset_basis INVARIANT 3) journals nothing
/// (not even the feature declaration) and changes nothing, now or on
/// reopen. A contribution's value is required.
#[test]
fn a_refused_tax_basis_journals_nothing() {
use ledger_core::TaxBasisRule::*;
fn a_refused_basis_journals_nothing() {
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, _) = fixture("refused-tax");
s.set_asset_tax_basis(x, tax(Carryover, Some("100.00")))
.unwrap();
let lines = journal(&dir).len();
let before = s.ledger().clone();
let mut purchase_with_basis = ledger_core::AssetBasis::purchase();
purchase_with_basis.contributor_adjusted_basis = Some(dec("100.00"));
let mut no_fmv = contribution(Carryover723, "100.00", "100.00");
no_fmv.fmv_at_contribution = None;
for bad in [
tax(Cost, Some("100.00")),
tax(Carryover, None),
tax(PersonalUseConversion, None),
tax(Carryover, Some("-0.01")),
tax(PersonalUseConversion, Some("10.001")),
purchase_with_basis,
no_fmv,
legacy(PersonalUseConversion, None),
contribution(Carryover723, "-0.01", "1.00"),
contribution(PersonalUseConversion, "10.001", "1.00"),
] {
assert!(
matches!(
s.set_asset_tax_basis(x, bad),
s.set_asset_basis(x, bad.clone()),
Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_)))
),
"{bad:?}"
);
}
assert!(matches!(
s.set_asset_tax_basis(x + 99, tax(Cost, None)),
s.set_asset_basis(x + 99, ledger_core::AssetBasis::purchase()),
Err(StoreError::Ledger(LedgerError::UnknownAsset(_)))
));
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
@@ -674,18 +701,27 @@ fn a_refused_tax_basis_journals_nothing() {
s.verify().unwrap();
}
/// Tax basis replays on reopen, with the database and rebuilt from the
/// journal alone, and verifies each time.
/// A basis replays on reopen, with the database and rebuilt from the
/// journal alone, and in the viewer, and verifies each time (its facts and
/// source included).
#[test]
fn tax_basis_replays_on_reopen() {
use ledger_core::TaxBasisRule::*;
fn asset_basis_set_journals_replays_and_reloads() {
use ledger_core::BasisRule::*;
let (dir, mut s, a, x, y) = fixture("replay-tax");
let z = laptop(&mut s, &a, "Laptop C");
s.set_asset_tax_basis(x, tax(Carryover, Some("900.00")))
s.set_asset_basis(x, contribution(Carryover723, "900.00", "1200.00"))
.unwrap();
s.set_asset_tax_basis(x, tax(PersonalUseConversion, Some("2000.00")))
let mut b = contribution(PersonalUseConversion, "2000.00", "1200.00");
b.sec704c_method = Some(ledger_core::Sec704cMethod::Remedial);
b.source = Some(ledger_core::settings::Source {
decided_by: "Tobin Harlow".into(),
decided_on: date(2026, 10, 20),
citation: Some("entry 3".into()),
note: Some("value as booked".into()),
});
s.set_asset_basis(x, b.clone()).unwrap();
s.set_asset_basis(y, ledger_core::AssetBasis::purchase())
.unwrap();
s.set_asset_tax_basis(y, tax(Cost, None)).unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
@@ -696,34 +732,223 @@ fn tax_basis_replays_on_reopen() {
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
assert_eq!(
tax_of(&s, x),
Some(tax(PersonalUseConversion, Some("2000.00")))
);
assert_eq!(tax_of(&s, y), Some(tax(Cost, None)));
assert_eq!(tax_of(&s, z), None);
assert_eq!(basis_of(&s, x), Some(b.clone()));
assert_eq!(basis_of(&s, y), Some(ledger_core::AssetBasis::purchase()));
assert_eq!(basis_of(&s, z), None);
let ax = s.ledger().get_asset(x).unwrap();
assert_eq!(ax.tax_basis(), Some("1200.00".parse().unwrap()));
assert_eq!(ax.built_in_gain(), Some("-800.00".parse().unwrap()));
assert_eq!(ax.depreciable_basis(), Some(dec("1200.00")));
assert_eq!(ax.built_in_gain_704c(), Some(dec("-800.00")));
drop(s);
}
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger(), &live, "the viewer");
}
/// Pre-P3 `asset.tax_basis_set` lines replay to the figures that version
/// gave, with the database and rebuilt from the journal.
#[test]
fn legacy_tax_basis_set_lines_replay_to_todays_figures() {
use ledger_core::BasisRule::*;
let (dir, s, _, x, y) = fixture("legacy-tax");
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": x, "rule": "personal_use_conversion", "contributor_basis": "1500.25"}),
)
.unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": y, "rule": "cost", "contributor_basis": null}),
)
.unwrap();
drop(w);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(
basis_of(&s, x),
Some(legacy(PersonalUseConversion, Some("1500.25")))
);
assert_eq!(basis_of(&s, y), Some(legacy(Purchase, None)));
let ax = s.ledger().get_asset(x).unwrap();
// The lesser of the 1,200.00 book cost and the 1,500.25 basis.
assert_eq!(ax.depreciable_basis(), Some(dec("1200.00")));
assert_eq!(ax.tax_basis_723(), Some(dec("1500.25")));
assert_eq!(ax.built_in_gain_704c(), Some(dec("-300.25")));
assert!(!ax.fmv_is_recorded());
drop(s);
}
}
/// Review R1: a line's figures are for its readers; replay never
/// recomputes them, so a line whose figures disagree with today's formula
/// (a later version's, or a CPA's correction) still replays, and the
/// books show today's figures from its facts.
#[test]
fn a_basis_set_whose_figures_disagree_still_replays() {
let (dir, s, _, x, _) = fixture("figures-disagree");
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["asset_basis"]}),
)
.unwrap();
w.append(
"asset.basis_set",
json!({"asset_id": x, "rule": "personal_use_conversion",
"contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00",
"sec704c_method": null, "source": null,
"tax_basis_723": "1.00", "depreciable_basis": "1500.00", "built_in_gain_704c": "0"}),
)
.unwrap();
drop(w);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
let a = s.ledger().get_asset(x).unwrap();
assert_eq!(a.depreciable_basis(), Some(dec("1200.00")));
assert_eq!(a.tax_basis_723(), Some(dec("1500.00")));
assert_eq!(a.built_in_gain_704c(), Some(dec("-300.00")));
drop(s);
}
}
/// A basis rule or §704(c) method this version doesn't know was written
/// by a newer one: NewerJournal, never corrupt (rules.md B-27); a figure
/// or fact that isn't a decimal string (or null) is corrupt.
#[test]
fn an_unknown_basis_rule_or_method_is_newer() {
let base = json!({"rule": "carryover_723", "contributor_adjusted_basis": "1.00",
"fmv_at_contribution": "1.00", "sec704c_method": null, "source": null,
"tax_basis_723": "1.00", "depreciable_basis": "1.00", "built_in_gain_704c": "0.00"});
let with = |k: &str, v: Value| {
let mut p = base.clone();
p[k] = v;
p
};
for (bad, newer) in [
(with("rule", json!("gift_1015")), true),
(with("sec704c_method", json!("aggregated")), true),
(with("rule", json!(7)), false),
(with("sec704c_method", json!(1)), false),
(with("fmv_at_contribution", json!(1.5)), false),
(with("tax_basis_723", json!("one")), false),
(with("source", json!({"decided_by": 1})), false),
(with("rule", json!("cost")), true),
] {
let (dir, s, _, x, _) = fixture("newer-basis");
drop(s);
let mut p = bad.clone();
p["asset_id"] = json!(x);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["asset_basis"]}),
)
.unwrap();
w.append("asset.basis_set", p).unwrap();
drop(w);
match (Store::open(&dir), newer) {
(Err(StoreError::NewerJournal { .. }), true) => {}
(Err(StoreError::JournalCorrupt(_)), false) => {}
(Err(other), _) => panic!("{bad}: got {other:?}"),
(Ok(_), _) => panic!("{bad}: opened"),
}
}
}
/// Downgrade the database to schema v12 (none of v13's 1099 decisions or
/// payment methods, nor its basis columns; runs keyed by asset and
/// period), as P2 left it.
fn downgrade_db_to_v12(db: &Path) {
let conn = rusqlite::Connection::open(db).unwrap();
conn.execute_batch(
"DROP TABLE vendor_1099_decisions;
DROP TABLE instruments;
DROP TABLE entry_instruments;
ALTER TABLE assets DROP COLUMN fmv_at_contribution;
ALTER TABLE assets DROP COLUMN sec704c_method;
ALTER TABLE assets DROP COLUMN basis_source_json;
CREATE TABLE asset_depreciation_v12(asset_id INTEGER NOT NULL, period TEXT NOT NULL,
entry_id INTEGER NOT NULL, amount TEXT NOT NULL, PRIMARY KEY (asset_id, period));
INSERT INTO asset_depreciation_v12 SELECT asset_id, period, entry_id, amount FROM asset_depreciation;
DROP TABLE asset_depreciation;
ALTER TABLE asset_depreciation_v12 RENAME TO asset_depreciation;
UPDATE meta SET value = '12' WHERE key = 'schema_version';",
)
.unwrap();
}
/// Downgrade the database to schema v10 (none of v11's attachment links,
/// statements, reconciliations, or payroll_lines.employee_id).
fn downgrade_db_to_v10(dir: &Path) {
downgrade_db_to_v12(&dir.join(db_file()));
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute_batch(
"DROP TABLE attachment_links;
DROP TABLE statements;
DROP TABLE reconciliations;
DROP TABLE reconciliation_lines;
DROP TABLE settings;
DROP TABLE conventions;
DROP TABLE decisions;
DROP TABLE open_items;
DROP TABLE entry_approvals;
ALTER TABLE payroll_lines DROP COLUMN employee_id;
UPDATE meta SET value = '10' WHERE key = 'schema_version';",
)
.unwrap();
}
/// P2's database (`wanderbooks.db`, schema v12) is left as it is, byte
/// for byte: this version builds its own v13 file from the journal, its
/// runs table keyed by entry with every run kept and the new columns
/// empty, and it verifies; a basis set after then reloads from it (each
/// schema in a file of its own: rules.md B-27).
#[test]
fn a_p2_database_is_left_and_v13_built_from_the_journal() {
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, y) = fixture("v12");
s.run_depreciation(x, "2026-01").unwrap();
drop(s);
// As P2 left the books: its schema in the legacy file, none of this
// version's.
let legacy = dir.join(ledger_store::LEGACY_DB_FILE);
std::fs::rename(dir.join(db_file()), &legacy).unwrap();
downgrade_db_to_v12(&legacy);
let p2 = std::fs::read(&legacy).unwrap();
let mut s = Store::open(&dir).expect("P2's books open");
assert_eq!(s.database_file(), db_file());
s.verify().unwrap();
assert_eq!(s.ledger().depreciation_runs(x).len(), 1);
assert_eq!(basis_of(&s, y), None);
s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00"))
.unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(
basis_of(&s, y),
Some(contribution(Carryover723, "640.00", "1200.00"))
);
assert_eq!(s.ledger().depreciation_runs(x).len(), 1);
drop(s);
assert_eq!(
std::fs::read(&legacy).unwrap(),
p2,
"P2's file, byte for byte"
);
}
/// A database at schema v10 under this version's own file name is rebuilt
/// from the journal as the books open (as the v8 one above), and verifies.
#[test]
@@ -752,10 +977,10 @@ fn downgrade_db_to_v9(dir: &Path) {
}
/// A database at schema v9 under this version's own file name is rebuilt
/// as the books open (as the v8 one above), verifies, and takes tax bases.
/// as the books open (as the v8 one above), verifies, and takes bases.
#[test]
fn a_v9_database_migrates_and_verifies() {
use ledger_core::TaxBasisRule::*;
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, y) = fixture("v9");
s.set_asset_tag(x, "FL-0001").unwrap();
drop(s);
@@ -764,21 +989,24 @@ fn a_v9_database_migrates_and_verifies() {
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(tax_of(&s, x), None);
s.set_asset_tax_basis(y, tax(Carryover, Some("640.00")))
assert_eq!(basis_of(&s, x), None);
s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00"))
.unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(tax_of(&s, y), Some(tax(Carryover, Some("640.00"))));
assert_eq!(
basis_of(&s, y),
Some(contribution(Carryover723, "640.00", "1200.00"))
);
}
/// A v9 database behind a journal with a tax basis line is rebuilt from
/// the whole journal, that line with it, and verifies.
#[test]
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
use ledger_core::TaxBasisRule::*;
use ledger_core::BasisRule::*;
let (dir, s, _, x, _) = fixture("v9-behind");
drop(s);
downgrade_db_to_v9(&dir);
@@ -792,7 +1020,7 @@ fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
let s = Store::open(&dir).expect("opens and catches up");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00"))));
assert_eq!(basis_of(&s, x), Some(legacy(Carryover723, Some("300.00"))));
}
/// An `asset.tax_basis_set` line with an unknown rule, or a contributor
@@ -841,7 +1069,7 @@ fn an_asset_is_corrected_until_depreciation_is_posted() {
cost: usd(cost),
salvage_value: usd("0.00"),
useful_life_months: 12,
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
};
let before = journal(&dir).len();
assert!(matches!(
@@ -903,3 +1131,85 @@ fn reversing_a_linked_acquisition_still_opens_and_verifies() {
drop(s);
Store::open(&dir).unwrap().verify().unwrap();
}
// ------------------------------------------------- depreciation runs (rules.md B-24)
/// A run is the books' record of what they carry for an asset
/// (Ledger::accumulated_posted): recorded live, replayed from the journal,
/// reloaded from the database, the same in the viewer, and verify
/// catches a run the database holds differently from the journal (review
/// R5).
#[test]
fn runs_replay_reload_and_verify_catches_a_tampered_run() {
let (dir, mut s, _, x, _) = fixture("runs");
s.run_depreciation(x, "2026-01").unwrap();
s.run_depreciation(x, "2026-02").unwrap();
let live = s.ledger().depreciation_runs(x).to_vec();
assert_eq!(live.len(), 2);
assert_eq!(
s.ledger().accumulated_posted(x, date(2026, 2, 28)).unwrap(),
dec("200.00")
);
s.verify().unwrap();
drop(s);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().depreciation_runs(x), live.as_slice());
s.verify().unwrap();
drop(s);
}
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().depreciation_runs(x), live.as_slice());
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute(
"UPDATE asset_depreciation SET amount = '99.00' WHERE period = '2026-02'",
[],
)
.unwrap();
drop(conn);
let s = Store::open(&dir).unwrap();
assert!(matches!(s.verify(), Err(StoreError::VerifyMismatch(_))));
}
/// The writer and the viewer read an `asset.depreciation_posted` line the
/// same way: well formed and naming an asset and an entry that exist, it
/// replays (any period an earlier version journaled, rules.md B-28);
/// otherwise both refuse it as corrupt.
#[test]
fn the_viewer_and_writer_agree_on_a_bad_depreciation_posted_payload() {
for (amount, asset_known, ok) in [
("lots", true, false),
("1.00", false, false),
("0.001", true, true),
] {
let (dir, mut s, a, x, _) = fixture("bad-run");
let e = s.create_entry(date(2026, 1, 31), "depreciation").unwrap();
s.add_line(e, a.expense, ledger_core::Side::Debit, usd("1.00"))
.unwrap();
s.add_line(e, a.accum, ledger_core::Side::Credit, usd("1.00"))
.unwrap();
s.post_entry(e).unwrap();
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.depreciation_posted",
json!({"asset_id": if asset_known { x } else { x + 99 }, "period": "10000-01",
"entry_id": e, "amount": amount}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(db_file()));
let writer = Store::open(&dir).map(|s| s.ledger().depreciation_runs(x).len());
let viewer =
ledger_store::Snapshot::load(&dir).map(|s| s.ledger().depreciation_runs(x).len());
assert_eq!(writer.is_ok(), ok, "{amount} {asset_known}: {writer:?}");
assert_eq!(viewer.is_ok(), ok, "{amount} {asset_known}: {viewer:?}");
if ok {
assert_eq!(writer.unwrap(), 1);
assert_eq!(viewer.unwrap(), 1);
}
}
}
+6 -6
View File
@@ -61,7 +61,7 @@ fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense
store
.register_asset(NewAsset {
description: "Sprinter van".to_string(),
purchase_date: date(2026, 1, 15),
in_service_date: date(2026, 1, 15),
cost: usd(&dollars.to_string()),
salvage_value: usd("0"),
useful_life_months: 60,
@@ -230,7 +230,7 @@ fn rejected_asset_registration_journals_nothing() {
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
let bad = NewAsset {
description: "Bad".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("1000"),
salvage_value: usd("1000"), // salvage >= cost: invalid
useful_life_months: 12,
@@ -255,7 +255,7 @@ fn asset_x(store: &mut Store, cost: u64, accum: u64, expense: u64) -> u64 {
store
.register_asset(NewAsset {
description: "Asset X".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("12000.00"),
salvage_value: usd("0"),
useful_life_months: 12,
@@ -313,7 +313,7 @@ fn defect22_a_disposal_in_the_final_month_posts_depreciation_by_days() {
// The books' accumulated equals the disposed schedule's.
let a = store.ledger().get_asset(x).unwrap();
assert_eq!(
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
dec("11322.58")
);
store.verify().unwrap();
@@ -552,7 +552,7 @@ fn run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing() {
let tiny = store
.register_asset(NewAsset {
description: "Cable".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("0.01"),
salvage_value: usd("0"),
useful_life_months: 3,
@@ -587,7 +587,7 @@ fn jpy_depreciation_runs_post_whole_yen() {
let id = store
.register_asset(NewAsset {
description: "Lathe".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: yen("100000"),
salvage_value: yen("0"),
useful_life_months: 12,
+1 -1
View File
@@ -302,7 +302,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
let asset = s
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: date(2026, 1, 5),
in_service_date: date(2026, 1, 5),
cost: usd("3000.00"),
salvage_value: usd("0"),
useful_life_months: 36,
+1 -1
View File
@@ -199,7 +199,7 @@ fn disposal_proceeds_larger_than_a_line_are_refused() {
let id = s
.register_asset(ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 1, 1),
in_service_date: d(2026, 1, 1),
cost: usd(Decimal::new(240000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 24,
+1 -1
View File
@@ -1280,7 +1280,7 @@ fn a_write_waiting_for_the_upgrade_journals_nothing() {
let lathe = |s: &mut Store, what: &str| {
s.register_asset(ledger_core::NewAsset {
description: what.into(),
purchase_date: day(2026, 1, 1),
in_service_date: day(2026, 1, 1),
cost: usd(1_200_000),
salvage_value: usd(0),
useful_life_months: 12,
+1 -1
View File
@@ -465,7 +465,7 @@ fn inv5_refused_disposal_writes_nothing() {
.store
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd(3_600),
salvage_value: usd(0),
useful_life_months: 36,
+6 -6
View File
@@ -1365,7 +1365,7 @@ mod refusals {
Op::RegisterAsset(c, d, x, cost, m) => store
.register_asset(ledger_core::NewAsset {
description: "van".into(),
purchase_date: day(0),
in_service_date: day(0),
cost: usd(cost),
salvage_value: usd(0),
useful_life_months: m,
@@ -1448,7 +1448,7 @@ mod refusals {
Op::GoodAsset(c, m) => store
.register_asset(ledger_core::NewAsset {
description: "machine".into(),
purchase_date: day(0),
in_service_date: day(0),
cost: usd(c),
salvage_value: usd(0),
useful_life_months: m,
@@ -1939,7 +1939,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
let dates: Vec<NaiveDate> = (1..l.next_entry_id())
.filter_map(|id| l.get_entry(id))
.map(|e| e.date())
.chain(l.registered_assets().map(|a| a.purchase_date))
.chain(l.registered_assets().map(|a| a.in_service_date))
.chain(l.list_periods().iter().flat_map(|p| [p.start, p.end]))
.chain(l.schedules().map(|s| s.start))
.collect();
@@ -1995,7 +1995,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
}
for a in l.registered_assets() {
tally(ledger_core::depreciation_schedule(a, d).map(|_| ()));
tally(ledger_core::book_value(a, d).map(|_| ()));
tally(ledger_core::scheduled_book_value(a, d).map(|_| ()));
tally(
ledger_core::disposal_amounts(
a,
@@ -2253,7 +2253,7 @@ fn scrapping_an_asset_for_nothing_disposes_it() {
let asset = store
.register_asset(ledger_core::NewAsset {
description: "laptop".into(),
purchase_date: day(1, 1),
in_service_date: day(1, 1),
cost: usd(1200),
salvage_value: usd(0),
useful_life_months: 12,
@@ -2442,7 +2442,7 @@ fn laptop(dir: &std::path::Path) -> (Store, u64, u64, u64) {
let asset = store
.register_asset(ledger_core::NewAsset {
description: "laptop".into(),
purchase_date: day(1, 1),
in_service_date: day(1, 1),
cost: usd(1200),
salvage_value: usd(0),
useful_life_months: 12,