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
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:
81 files changed
+9391
-2604
No files matched your search
@@ -3,7 +3,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset,
|
||||
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
|
||||
accumulated_scheduled_through, depreciation_schedule, disposal_amounts, scheduled_book_value,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
@@ -53,7 +53,7 @@ fn van_asset(
|
||||
ledger
|
||||
.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,
|
||||
@@ -112,7 +112,7 @@ fn salvage_value_is_never_depreciated() {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Machine".to_string(),
|
||||
purchase_date: date(2026, 3, 1),
|
||||
in_service_date: date(2026, 3, 1),
|
||||
cost: usd("50000"),
|
||||
salvage_value: usd("5000"),
|
||||
useful_life_months: 36,
|
||||
@@ -133,11 +133,11 @@ fn book_value_tracks_accumulated() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let acc = accumulated_depreciation(asset, date(2026, 12, 31)).unwrap();
|
||||
let acc = accumulated_scheduled_through(asset, date(2026, 12, 31)).unwrap();
|
||||
// Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000.
|
||||
assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
|
||||
assert_eq!(
|
||||
book_value(asset, date(2026, 12, 31)).unwrap(),
|
||||
scheduled_book_value(asset, date(2026, 12, 31)).unwrap(),
|
||||
Decimal::from(60_000) - acc
|
||||
);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ fn registration_validation() {
|
||||
.unwrap();
|
||||
let base = NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
@@ -222,7 +222,7 @@ fn registration_rejects_date_overflow() {
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Time machine".to_string(),
|
||||
purchase_date: NaiveDate::MAX,
|
||||
in_service_date: NaiveDate::MAX,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
@@ -240,7 +240,7 @@ fn registration_rejects_date_overflow() {
|
||||
// could still reach (P0 review round 3, S1).
|
||||
let new = |date: NaiveDate, months: u32| NewAsset {
|
||||
description: "Late machine".to_string(),
|
||||
purchase_date: date,
|
||||
in_service_date: date,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: months,
|
||||
@@ -270,7 +270,7 @@ fn schedule_through_purchase_date_covers_one_day() {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
in_service_date: date(2026, 1, 15),
|
||||
cost: usd("62000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 62,
|
||||
@@ -335,7 +335,7 @@ fn registration_rejects_wrong_typed_accounts() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let base = || NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
in_service_date: date(2026, 1, 15),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
@@ -415,7 +415,7 @@ fn one_month_useful_life_is_valid() {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Cheap tool".to_string(),
|
||||
purchase_date: date(2026, 3, 10),
|
||||
in_service_date: date(2026, 3, 10),
|
||||
cost: usd("300"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 1,
|
||||
@@ -445,11 +445,12 @@ fn disposal_on_the_purchase_date_is_allowed() {
|
||||
assert_eq!(d.accumulated, Decimal::new(3226, 2));
|
||||
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
|
||||
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
|
||||
// The day before purchase is not a disposal.
|
||||
assert!(matches!(
|
||||
disposal_amounts(&asset, date(2026, 1, 14), usd("60000")),
|
||||
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
// The day before it is in service nothing has accrued (whether the
|
||||
// company held it by then is its acquisition entry's: the ledger
|
||||
// checks that floor, Ledger::dispose_asset).
|
||||
let d = disposal_amounts(&asset, date(2026, 1, 14), usd("60000")).unwrap();
|
||||
assert_eq!(d.accumulated, Decimal::ZERO);
|
||||
assert_eq!(d.book_value, asset.cost.amount);
|
||||
}
|
||||
|
||||
/// An asset links to the posted entry that acquired it, if that entry
|
||||
|
||||
@@ -11,24 +11,27 @@
|
||||
//! due: a depreciation run, a disposal, a schedule run or registration),
|
||||
//! each accepted or refused as it says, with the parties, payee and
|
||||
//! payment method it is tagged with, and every change that posts no entry
|
||||
//! (a rename, a policy). The books are the engine's own store
|
||||
//! (`ledger_store::Store`, in a directory of their own), so every event
|
||||
//! goes through the command the books take it by, journaled, and the
|
||||
//! books must replay to what was kept (`Store::verify`). The queries the
|
||||
//! (a rename, a policy, an asset's correction). The books are the
|
||||
//! engine's own store (`ledger_store::Store`, in a directory of their
|
||||
//! own), so every event goes through the command the books take it by,
|
||||
//! journaled, and the books must replay to what was kept
|
||||
//! (`Store::verify`). The queries the
|
||||
//! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
|
||||
//! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`,
|
||||
//! `members_equity`, `k1`, `party_part`, `party_totals`,
|
||||
//! `party_tin_last4`, `period`, `day`, `calendar_year`, `account_names`,
|
||||
//! `uncategorized_accounts`, `run`, `disposal`, `depreciation_schedule`,
|
||||
//! `schedule_plan`) are then checked value by value, and the commands it
|
||||
//! tries
|
||||
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
|
||||
//! refused on a copy of the books, each after the event it names (`after`)
|
||||
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
|
||||
//! checked by ledger-package (`the_bookspec_iif_example_matches_the_engine`),
|
||||
//! and a query no crate answers is refused. A key the shape doesn't have
|
||||
//! is refused too, never ignored.
|
||||
//! `members_equity`, `k1`, `k1_tax_basis`, `k1_reconciliation`,
|
||||
//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`,
|
||||
//! `calendar_year`, `account_names`, `uncategorized_accounts`, `run`,
|
||||
//! `disposal`, `depreciation_schedule`, `schedule_plan`, `register_row`,
|
||||
//! `register_totals`, `asset_basis`) are then checked value by value, and
|
||||
//! the commands it tries (`rename_account`, `add_account`,
|
||||
//! `set_tax_category`) accepted or refused on a copy of the books, each
|
||||
//! after the event it names (`after`) or the last; the formats appendix's
|
||||
//! (`iif_lines`, `iif_changes`) are checked by ledger-package
|
||||
//! (`the_bookspec_iif_example_matches_the_engine`), and a query no crate
|
||||
//! answers is refused. A key the shape doesn't have is refused too, never
|
||||
//! ignored.
|
||||
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -36,6 +39,7 @@ use chrono::NaiveDate;
|
||||
use ledger_core::calendar::{PeriodDefaults, period_defaults};
|
||||
use ledger_core::parties::{Basis, MoneyAccounts};
|
||||
use ledger_core::settings::{Company, Source};
|
||||
use ledger_core::tax_capital::TaxCapitalRow;
|
||||
use ledger_core::{
|
||||
AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
|
||||
Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099,
|
||||
@@ -170,6 +174,32 @@ struct AssetSpec {
|
||||
cost_account: String,
|
||||
accumulated_account: String,
|
||||
expense_account: String,
|
||||
/// The event whose entry put it on the books (its purchase or a
|
||||
/// member's contribution), linked once that event is applied: its date
|
||||
/// is the asset's acquired date (rules.md B-15 rule 7).
|
||||
#[serde(default)]
|
||||
acquired_by: Option<usize>,
|
||||
/// How the company got it and the facts its tax figures follow from
|
||||
/// (rules.md B-25), recorded as it is registered.
|
||||
#[serde(default)]
|
||||
basis: Option<BasisSpec>,
|
||||
}
|
||||
|
||||
/// An asset's basis facts (rules.md B-25): `rule` `"purchase"`,
|
||||
/// `"carryover_723"` or `"personal_use_conversion"`; a contribution's
|
||||
/// `contributor_adjusted_basis` and `fmv_at_contribution`; its §704(c)
|
||||
/// method, if chosen (`"traditional"`, `"traditional_with_curative"`,
|
||||
/// `"remedial"`).
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BasisSpec {
|
||||
rule: String,
|
||||
#[serde(default)]
|
||||
contributor_adjusted_basis: Option<String>,
|
||||
#[serde(default)]
|
||||
fmv_at_contribution: Option<String>,
|
||||
#[serde(default)]
|
||||
sec704c_method: Option<String>,
|
||||
}
|
||||
|
||||
/// A prepaid expense or deferred revenue recognized over `months` from
|
||||
@@ -376,8 +406,9 @@ struct BillSpec {
|
||||
|
||||
/// A change to the books that posts no entry, made right after event
|
||||
/// `after` (counting from 1) once the expectations asked after that event
|
||||
/// are checked: a rename (`rename`) or the tax-category policy
|
||||
/// (`require_tax_category`), exactly one of them.
|
||||
/// are checked: a rename (`rename`), the tax-category policy
|
||||
/// (`require_tax_category`) or an asset's correction (`correct_asset`),
|
||||
/// exactly one of them.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ChangeSpec {
|
||||
@@ -388,6 +419,25 @@ struct ChangeSpec {
|
||||
/// "refuse" (rules.md B-32).
|
||||
#[serde(default)]
|
||||
require_tax_category: Option<String>,
|
||||
#[serde(default)]
|
||||
correct_asset: Option<CorrectSpec>,
|
||||
}
|
||||
|
||||
/// A registered asset's economics corrected (a registration mistake), for
|
||||
/// `reason`: each figure given replaces the asset's; the rest stay.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CorrectSpec {
|
||||
asset: String,
|
||||
#[serde(default)]
|
||||
cost: Option<String>,
|
||||
#[serde(default)]
|
||||
salvage: Option<String>,
|
||||
#[serde(default)]
|
||||
life_months: Option<u32>,
|
||||
#[serde(default)]
|
||||
in_service: Option<String>,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// Rename the account `code` to `name` (rules.md B-31).
|
||||
@@ -431,8 +481,9 @@ struct Expect {
|
||||
entry: Option<usize>,
|
||||
/// The `run_depreciation` event a `run` query asks about.
|
||||
event: Option<usize>,
|
||||
/// The fixed asset (`setup.assets` id) a `disposal` or
|
||||
/// `depreciation_schedule` query asks about.
|
||||
/// The fixed asset (`setup.assets` id) a `disposal`,
|
||||
/// `depreciation_schedule`, `register_row` or `asset_basis` query asks
|
||||
/// about.
|
||||
asset: Option<String>,
|
||||
/// The schedule (its id) a `schedule_plan` query asks about.
|
||||
schedule: Option<String>,
|
||||
@@ -792,6 +843,22 @@ fn add_parties(ex: &Example, store: &mut Store) -> BTreeMap<String, PartyId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// An asset's basis facts as the books record them.
|
||||
fn basis(b: &BasisSpec, what: &str) -> ledger_core::AssetBasis {
|
||||
let amount = |s: &Option<String>| s.as_deref().map(|v| exact(v, what));
|
||||
ledger_core::AssetBasis {
|
||||
rule: ledger_core::BasisRule::parse(&b.rule)
|
||||
.unwrap_or_else(|| panic!("{what}: basis rule {:?}", b.rule)),
|
||||
contributor_adjusted_basis: amount(&b.contributor_adjusted_basis),
|
||||
fmv_at_contribution: amount(&b.fmv_at_contribution),
|
||||
sec704c_method: b.sec704c_method.as_deref().map(|m| {
|
||||
ledger_core::Sec704cMethod::parse(m)
|
||||
.unwrap_or_else(|| panic!("{what}: §704(c) method {m:?}"))
|
||||
}),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A schedule as the books register it.
|
||||
fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule {
|
||||
let account = |code: &str| {
|
||||
@@ -892,7 +959,7 @@ impl<'a> Books<'a> {
|
||||
for fa in &s.fixed_assets {
|
||||
let new = NewAsset {
|
||||
description: format!("Fixed asset on {}", fa.cost_account),
|
||||
purchase_date: first,
|
||||
in_service_date: first,
|
||||
cost: Money::new(Decimal::ONE, currency),
|
||||
salvage_value: Money::new(Decimal::ZERO, currency),
|
||||
useful_life_months: 12,
|
||||
@@ -911,7 +978,7 @@ impl<'a> Books<'a> {
|
||||
assert_eq!(a.method, "straight_line", "{what}: method");
|
||||
let new = NewAsset {
|
||||
description: a.description.clone(),
|
||||
purchase_date: date(&a.in_service, &what),
|
||||
in_service_date: date(&a.in_service, &what),
|
||||
cost: Money::new(exact(&a.cost, &what), currency),
|
||||
salvage_value: Money::new(exact(&a.salvage, &what), currency),
|
||||
useful_life_months: a.life_months,
|
||||
@@ -924,6 +991,15 @@ impl<'a> Books<'a> {
|
||||
assets.insert(a.id.clone(), asset).is_none(),
|
||||
"{what}: id used twice"
|
||||
);
|
||||
if let Some(b) = &a.basis {
|
||||
took(store.set_asset_basis(asset, basis(b, &what)), &what);
|
||||
}
|
||||
assert!(
|
||||
a.acquired_by.is_none_or(|n| n >= 1 && n <= ex.events.len()),
|
||||
"{what}: acquired_by = {:?}: events count from 1, to {}",
|
||||
a.acquired_by,
|
||||
ex.events.len()
|
||||
);
|
||||
}
|
||||
let mut schedules = BTreeMap::new();
|
||||
for sc in &s.schedules {
|
||||
@@ -1190,6 +1266,17 @@ impl<'a> Books<'a> {
|
||||
assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry");
|
||||
}
|
||||
self.posted.push(posted);
|
||||
// The assets this event's entry put on the books.
|
||||
for a in spec
|
||||
.setup
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|a| a.acquired_by == Some(n))
|
||||
{
|
||||
let entry = self.entry_of(n, &format!("{what}: asset {}'s acquired_by", a.id));
|
||||
let asset = self.asset(&a.id, &what);
|
||||
took(self.store.set_asset_acquisition(asset, entry), &what);
|
||||
}
|
||||
if !e.parties.is_empty() {
|
||||
let entry = self.entry_of(n, &what);
|
||||
let shares: Vec<PartyShare> = e
|
||||
@@ -1391,12 +1478,30 @@ impl Books<'_> {
|
||||
let ex = &self.ex.id;
|
||||
let after = c.after;
|
||||
let what = format!("{ex} change after {after}");
|
||||
match (&c.rename, &c.require_tax_category) {
|
||||
(Some(r), None) => {
|
||||
match (&c.rename, &c.require_tax_category, &c.correct_asset) {
|
||||
(Some(r), None, None) => {
|
||||
let account = self.id(&r.code);
|
||||
took(self.store.rename_account(account, &r.name, None), &what);
|
||||
}
|
||||
(None, Some(policy)) => {
|
||||
(None, None, Some(f)) => {
|
||||
let id = self.asset(&f.asset, &what);
|
||||
let a = self.l().get_asset(id).expect("registered").clone();
|
||||
let amount = |s: &Option<String>, was: Money| {
|
||||
s.as_deref()
|
||||
.map_or(was, |v| Money::new(exact(v, &what), self.currency))
|
||||
};
|
||||
let fix = ledger_core::AssetCorrection {
|
||||
cost: amount(&f.cost, a.cost),
|
||||
salvage_value: amount(&f.salvage, a.salvage_value),
|
||||
useful_life_months: f.life_months.unwrap_or(a.useful_life_months),
|
||||
in_service_date: f
|
||||
.in_service
|
||||
.as_deref()
|
||||
.map_or(a.in_service_date, |d| date(d, &what)),
|
||||
};
|
||||
took(self.store.correct_asset(id, fix, &f.reason), &what);
|
||||
}
|
||||
(None, Some(policy), None) => {
|
||||
assert!(
|
||||
matches!(policy.as_str(), "warn" | "refuse"),
|
||||
"{what}: require_tax_category {policy:?}"
|
||||
@@ -1411,7 +1516,7 @@ impl Books<'_> {
|
||||
&what,
|
||||
);
|
||||
}
|
||||
_ => panic!("{what}: a change is one of rename or require_tax_category"),
|
||||
_ => panic!("{what}: a change is one of rename, require_tax_category or correct_asset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2201,8 +2306,8 @@ fn check_depreciation_schedule(b: &Books<'_>, x: &Expect) {
|
||||
for (key, want) in x.figures(&ex.id) {
|
||||
let want = exact(want, &format!("{what}: {key}"));
|
||||
let got = match key.as_str() {
|
||||
"accumulated" => ledger_core::accumulated_depreciation(asset, as_of),
|
||||
"book_value" => ledger_core::book_value(asset, as_of),
|
||||
"accumulated" => ledger_core::accumulated_scheduled_through(asset, as_of),
|
||||
"book_value" => ledger_core::scheduled_book_value(asset, as_of),
|
||||
month => {
|
||||
month_key(month, &what);
|
||||
Ok(lines
|
||||
@@ -2255,6 +2360,254 @@ fn check_schedule_plan(b: &Books<'_>, x: &Expect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check expectations whose values are named figures: each key one of
|
||||
/// `figures`' (an unknown one refused), its value the figure's text.
|
||||
fn named<K: Borrow<str> + Ord>(
|
||||
ex: &Example,
|
||||
x: &Expect,
|
||||
what: &str,
|
||||
figures: &BTreeMap<K, String>,
|
||||
) {
|
||||
for (key, want) in x.figures(&ex.id) {
|
||||
let got = figures
|
||||
.get(key.as_str())
|
||||
.unwrap_or_else(|| panic!("{what}: no figure {key:?}"));
|
||||
assert_eq!(got, want, "{what}: {key}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A decimal figure as an example writes it: two places at least, as the
|
||||
/// books keep money (exact; "0.00", "-14.20").
|
||||
fn figure(d: Decimal) -> String {
|
||||
let mut d = d.normalize();
|
||||
if d.scale() < 2 {
|
||||
d.rescale(2);
|
||||
}
|
||||
d.to_string()
|
||||
}
|
||||
|
||||
/// Check one `register_row` expectation: the asset's row of the fixed
|
||||
/// asset register as of `as_of` (rules.md B-15 rules 7 and 8, B-24):
|
||||
/// `acquired` and `in_service` (`YYYY-MM-DD`), `cost`,
|
||||
/// `accumulated_scheduled` (the schedule's), `accumulated_posted` (what
|
||||
/// the books carry), `unposted` (the one less the other), `book_value`
|
||||
/// (cost less posted); each 0.00 once disposed; the example names the
|
||||
/// figures it shows. An asset not on the register by `as_of` (acquired
|
||||
/// later) is refused.
|
||||
fn check_register_row(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let as_of = x.day(ex);
|
||||
let name = x
|
||||
.asset
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("{}: register_row names its asset", ex.id));
|
||||
let what = format!("{} register_row of {name} as of {as_of}", ex.id);
|
||||
let id = b.asset(name, &what);
|
||||
let r =
|
||||
ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let row = r
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.asset_id == id)
|
||||
.unwrap_or_else(|| panic!("{what}: not on the register (acquired later)"));
|
||||
let figures = BTreeMap::from([
|
||||
("acquired", row.acquired_date.to_string()),
|
||||
("in_service", row.in_service_date.to_string()),
|
||||
("cost", figure(row.cost)),
|
||||
("accumulated_scheduled", figure(row.accumulated_scheduled)),
|
||||
("accumulated_posted", figure(row.accumulated_posted)),
|
||||
(
|
||||
"unposted",
|
||||
figure(row.accumulated_scheduled - row.accumulated_posted),
|
||||
),
|
||||
("book_value", figure(row.book_value)),
|
||||
]);
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// Check one `register_totals` expectation: the fixed asset register's
|
||||
/// totals as of `as_of`, over the assets held (rules.md B-24, B-25):
|
||||
/// `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`,
|
||||
/// `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`,
|
||||
/// `missing_basis` (how many held have none), and how it reconciles to
|
||||
/// the books: `unregistered_cost`, `unposted_depreciation`,
|
||||
/// `unattributed_accumulated`; the example names the figures it shows.
|
||||
fn check_register_totals(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let as_of = x.day(ex);
|
||||
let what = format!("{} register_totals as of {as_of}", ex.id);
|
||||
let r =
|
||||
ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let figures = BTreeMap::from([
|
||||
("total_cost", figure(r.total_cost)),
|
||||
(
|
||||
"total_accumulated_scheduled",
|
||||
figure(r.total_accumulated_scheduled),
|
||||
),
|
||||
(
|
||||
"total_accumulated_posted",
|
||||
figure(r.total_accumulated_posted),
|
||||
),
|
||||
("total_book_value", figure(r.total_book_value)),
|
||||
("total_depreciable_basis", figure(r.total_depreciable_basis)),
|
||||
("total_tax_basis_723", figure(r.total_tax_basis_723)),
|
||||
("missing_basis", r.missing_basis.to_string()),
|
||||
("unregistered_cost", figure(r.unregistered_cost())),
|
||||
("unposted_depreciation", figure(r.unposted_depreciation())),
|
||||
(
|
||||
"unattributed_accumulated",
|
||||
figure(r.unattributed_accumulated()),
|
||||
),
|
||||
]);
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// Check one `asset_basis` expectation: the asset's basis facts and the
|
||||
/// tax figures they give (rules.md B-25): `rule`,
|
||||
/// `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`,
|
||||
/// `depreciable_basis`, `built_in_gain_704c` (a loss negative), each
|
||||
/// `"none"` where the asset has none (a purchase's §723 basis), and
|
||||
/// `sec704c_1c` (`"true"`: a built-in loss, which counts only for the
|
||||
/// contributing member, IRC §704(c)(1)(C)); the example names the figures
|
||||
/// it shows.
|
||||
fn check_asset_basis(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
undated(ex, x);
|
||||
let name = x
|
||||
.asset
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("{}: asset_basis names its asset", ex.id));
|
||||
let what = format!("{} asset_basis of {name}", ex.id);
|
||||
let a = b
|
||||
.l()
|
||||
.get_asset(b.asset(name, &what))
|
||||
.expect("a registered asset");
|
||||
let f = a
|
||||
.basis_figures()
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"))
|
||||
.unwrap_or_else(|| panic!("{what}: no basis recorded"));
|
||||
let opt = |d: Option<Decimal>| d.map_or("none".to_string(), figure);
|
||||
let rule = a.basis.as_ref().expect("a basis recorded").rule;
|
||||
let figures = BTreeMap::from([
|
||||
("rule", rule.key().to_string()),
|
||||
(
|
||||
"contributor_adjusted_basis",
|
||||
opt(a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis)),
|
||||
),
|
||||
("fmv_at_contribution", opt(a.fmv_at_contribution())),
|
||||
("tax_basis_723", opt(f.tax_basis_723)),
|
||||
("depreciable_basis", figure(f.depreciable_basis)),
|
||||
("built_in_gain_704c", opt(f.built_in_gain_704c)),
|
||||
("sec704c_1c", a.sec704c_1c().to_string()),
|
||||
]);
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// The tax-basis capital worksheet for `[from, to]` (rules.md B-36), a
|
||||
/// row a member, as `k1` reads the book one: it needs the members and
|
||||
/// their shares, and `cash_accounts`.
|
||||
fn tax_capital_rows(b: &Books<'_>, x: &Expect, what: &str) -> Vec<TaxCapitalRow> {
|
||||
let ex = b.ex;
|
||||
let (from, to) = x.period(ex);
|
||||
assert!(
|
||||
!b.members.is_empty() && b.members.iter().all(|m| m.share > Decimal::ZERO),
|
||||
"{what}: needs the members and their shares"
|
||||
);
|
||||
assert!(
|
||||
!ex.setup.cash_accounts.is_empty(),
|
||||
"{what}: the K-1 worksheet needs cash_accounts"
|
||||
);
|
||||
let cash = codes(b.l(), &ex.setup.cash_accounts, &ex.id);
|
||||
ledger_core::tax_capital::tax_basis_capital(b.l(), from, to, &b.members, &cash)
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"))
|
||||
.rows
|
||||
}
|
||||
|
||||
/// Check one `k1_tax_basis` expectation: each member's capital account
|
||||
/// for `[from, to]` on the tax basis, as Schedule K-1 Item L reports it
|
||||
/// (rules.md B-36), `"<member>.<line>"` for the lines `beginning`,
|
||||
/// `contributions_cash`, `contributions_property`, `net_income`, `other`,
|
||||
/// `distributions` and `ending`, beside `k1`'s book figures; a line the
|
||||
/// books can't know is `"open"`, with what they know so far as
|
||||
/// `"<member>.known_<line>"`. The example names the figures it shows.
|
||||
fn check_k1_tax_basis(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let what = format!("{} k1_tax_basis", ex.id);
|
||||
let mut figures = BTreeMap::new();
|
||||
for row in tax_capital_rows(b, x, &what) {
|
||||
let m = &row.book.name;
|
||||
figures.insert(
|
||||
format!("{m}.contributions_cash"),
|
||||
figure(row.contributions_cash),
|
||||
);
|
||||
for (line, tax) in [
|
||||
("beginning", &row.beginning),
|
||||
("contributions_property", &row.contributions_property),
|
||||
("net_income", &row.net_income),
|
||||
("other", &row.other),
|
||||
("distributions", &row.distributions),
|
||||
("ending", &row.ending),
|
||||
] {
|
||||
figures.insert(
|
||||
format!("{m}.{line}"),
|
||||
tax.tax().map_or("open".to_string(), figure),
|
||||
);
|
||||
figures.insert(format!("{m}.known_{line}"), figure(tax.known));
|
||||
}
|
||||
}
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// The causes `k1_reconciliation` reports, each member's every one.
|
||||
const RECONCILING_CAUSES: [&str; 5] = [
|
||||
"beginning",
|
||||
"built_in_gain_704c",
|
||||
"property_without_basis",
|
||||
"property_distributed",
|
||||
"total",
|
||||
];
|
||||
|
||||
/// Check one `k1_reconciliation` expectation: each member's book capital
|
||||
/// less tax-basis capital at the end of `[from, to]`, by cause (rules.md
|
||||
/// B-36), `"<member>.<cause>"` for `beginning`, `built_in_gain_704c`
|
||||
/// (their share of the §704(c) gain or loss at contribution),
|
||||
/// `property_without_basis`, `property_distributed` and `total`: the
|
||||
/// example names the members it shows, each with every cause.
|
||||
fn check_k1_reconciliation(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let what = format!("{} k1_reconciliation", ex.id);
|
||||
let mut figures = BTreeMap::new();
|
||||
for row in tax_capital_rows(b, x, &what) {
|
||||
let r = &row.reconciliation;
|
||||
let total = r
|
||||
.total()
|
||||
.unwrap_or_else(|| panic!("{what}: the total is too large"));
|
||||
for (cause, v) in RECONCILING_CAUSES.into_iter().zip([
|
||||
r.beginning,
|
||||
r.built_in_gain_704c,
|
||||
r.property_without_basis,
|
||||
r.property_distributed,
|
||||
total,
|
||||
]) {
|
||||
figures.insert(format!("{}.{cause}", row.book.name), figure(v));
|
||||
}
|
||||
}
|
||||
let named_members: BTreeSet<&str> = x
|
||||
.figures(&ex.id)
|
||||
.keys()
|
||||
.map(|k| k.split_once('.').map_or(k.as_str(), |(m, _)| m))
|
||||
.collect();
|
||||
for m in named_members {
|
||||
for cause in RECONCILING_CAUSES {
|
||||
assert!(
|
||||
x.figures(&ex.id).contains_key(&format!("{m}.{cause}")),
|
||||
"{what}: {m} is shown with every cause: {m}.{cause} is missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// The commands an example may try: each on a copy of the books, so it
|
||||
/// changes nothing that is checked after it.
|
||||
const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"];
|
||||
@@ -2474,7 +2827,12 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
||||
only(
|
||||
x.asset.is_some(),
|
||||
"asset",
|
||||
&["disposal", "depreciation_schedule"],
|
||||
&[
|
||||
"disposal",
|
||||
"depreciation_schedule",
|
||||
"register_row",
|
||||
"asset_basis",
|
||||
],
|
||||
);
|
||||
only(x.schedule.is_some(), "schedule", &["schedule_plan"]);
|
||||
only(
|
||||
@@ -2535,6 +2893,11 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
||||
"disposal" => check_disposal(b, x),
|
||||
"depreciation_schedule" => check_depreciation_schedule(b, x),
|
||||
"schedule_plan" => check_schedule_plan(b, x),
|
||||
"register_row" => check_register_row(b, x),
|
||||
"register_totals" => check_register_totals(b, x),
|
||||
"asset_basis" => check_asset_basis(b, x),
|
||||
"k1_tax_basis" => check_k1_tax_basis(b, x),
|
||||
"k1_reconciliation" => check_k1_reconciliation(b, x),
|
||||
"balances" => check_balances(ex, l, x),
|
||||
"members_equity" => check_members_equity(ex, l, &b.members, x),
|
||||
"k1" => check_k1(ex, l, &b.members, x),
|
||||
@@ -2632,7 +2995,8 @@ fn every_bookspec_example_holds() {
|
||||
// Each query the examples use ran: the cash flow's six examples at
|
||||
// least, the as-of and 1099 examples', the members closes', the
|
||||
// formats appendix's, and P3's default period, rename, tax category,
|
||||
// parties, 1099, depreciation and schedules examples'.
|
||||
// parties, 1099, depreciation, schedules, asset basis and K-1 tax-basis
|
||||
// examples'.
|
||||
for (q, at_least) in [
|
||||
("cash_flow", 6),
|
||||
("trial_balance", 2),
|
||||
@@ -2642,7 +3006,7 @@ fn every_bookspec_example_holds() {
|
||||
("entry", 8),
|
||||
("balances", 4),
|
||||
("members_equity", 10),
|
||||
("k1", 2),
|
||||
("k1", 3),
|
||||
("party_part", 28),
|
||||
("party_totals", 23),
|
||||
("party_tin_last4", 5),
|
||||
@@ -2660,6 +3024,11 @@ fn every_bookspec_example_holds() {
|
||||
("disposal", 5),
|
||||
("depreciation_schedule", 5),
|
||||
("schedule_plan", 3),
|
||||
("register_row", 5),
|
||||
("register_totals", 1),
|
||||
("asset_basis", 6),
|
||||
("k1_tax_basis", 2),
|
||||
("k1_reconciliation", 1),
|
||||
] {
|
||||
let n = checked.get(q).copied().unwrap_or(0);
|
||||
assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}");
|
||||
@@ -3147,3 +3516,52 @@ fn a_schedule_month_off_by_a_cent_fails() {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// An asset is on the register from its acquired date: a row asked for
|
||||
/// before it is refused, never shown with figures.
|
||||
#[test]
|
||||
#[should_panic(expected = "not on the register (acquired later)")]
|
||||
fn a_register_row_before_the_asset_was_acquired_is_refused() {
|
||||
run_edited("assets-acquired-before-in-service.toml", |t| {
|
||||
t.replacen("as_of = \"2026-03-31\"", "as_of = \"2026-03-09\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// `acquired_by` names an event whose entry put the asset on the books:
|
||||
/// an event that posts no entry can't.
|
||||
#[test]
|
||||
#[should_panic(expected = "event 2 posted 0 entries")]
|
||||
fn an_asset_acquired_by_an_event_with_no_entry_is_refused() {
|
||||
run_edited("assets-a-reversed-run-is-not-posted.toml", |t| {
|
||||
t.replacen("acquired_by = 1", "acquired_by = 2", 1)
|
||||
.replacen(
|
||||
"[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2026-01\" }",
|
||||
"[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2025-12\" }\nrefused = \"invalid_depreciation_period\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// A tax line the books can't know is "open", never a figure: an example
|
||||
/// giving the book figure for it fails.
|
||||
#[test]
|
||||
#[should_panic(expected = "k1_tax_basis: Tobin Harlow.net_income")]
|
||||
fn a_tax_line_the_books_cant_know_is_open() {
|
||||
run_edited("partnership-k1-tax-basis-capital.toml", |t| {
|
||||
t.replacen(
|
||||
"\"Tobin Harlow.net_income\" = \"open\"",
|
||||
"\"Tobin Harlow.net_income\" = \"-385.00\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// A member's reconciliation is shown whole: an example leaving out a
|
||||
/// cause fails, so a figure can't quietly go unchecked.
|
||||
#[test]
|
||||
#[should_panic(expected = "Tobin Harlow.property_distributed is missing")]
|
||||
fn a_members_reconciliation_shows_every_cause() {
|
||||
run_edited("partnership-k1-tax-basis-capital.toml", |t| {
|
||||
t.replacen(", \"Tobin Harlow.property_distributed\" = \"0.00\"", "", 1)
|
||||
});
|
||||
}
|
||||
@@ -1035,7 +1035,7 @@ fn a_sale_to_a_contractor_does_not_lower_its_1099() {
|
||||
let depr = acct(&mut m.l, "6800", AccountType::Expense);
|
||||
m.l.register_asset(NewAsset {
|
||||
description: "Monitor".into(),
|
||||
purchase_date: d(2024, 1, 1),
|
||||
in_service_date: d(2024, 1, 1),
|
||||
cost: Money::new(dec("1000"), Currency::USD),
|
||||
salvage_value: Money::new(dec("0"), Currency::USD),
|
||||
useful_life_months: 36,
|
||||
|
||||
@@ -863,7 +863,7 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
||||
let asset = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
in_service_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
cost: usd(60_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 60,
|
||||
|
||||
@@ -461,4 +461,27 @@ proptest! {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// kernel::basis_figures at i64 (what Kani's basis_figures_laws
|
||||
/// proved) and at Decimal agree, rule by rule.
|
||||
#[test]
|
||||
fn basis_figures_agree(
|
||||
rule in prop_oneof![
|
||||
Just(ledger_core::BasisRule::Purchase),
|
||||
Just(ledger_core::BasisRule::Carryover723),
|
||||
Just(ledger_core::BasisRule::PersonalUseConversion),
|
||||
],
|
||||
cost in arb_cents(),
|
||||
adjusted in arb_cents(),
|
||||
fmv in arb_cents(),
|
||||
) {
|
||||
let i = kernel::basis_figures(rule, cost, adjusted, fmv);
|
||||
let d = kernel::basis_figures(rule, dec(cost), dec(adjusted), dec(fmv));
|
||||
prop_assert_eq!(i.is_some(), d.is_some());
|
||||
if let (Some(i), Some(d)) = (i, d) {
|
||||
prop_assert_eq!(i.tax_basis_723.map(dec), d.tax_basis_723);
|
||||
prop_assert_eq!(dec(i.depreciable_basis), d.depreciable_basis);
|
||||
prop_assert_eq!(i.built_in_gain_704c.map(dec), d.built_in_gain_704c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_scheduled_through,
|
||||
depreciation_schedule,
|
||||
reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
|
||||
unclosed_earnings,
|
||||
},
|
||||
scheduled_book_value,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -290,7 +291,7 @@ proptest! {
|
||||
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
|
||||
let id = ledger.register_asset(NewAsset {
|
||||
description: "prop".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
|
||||
in_service_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
|
||||
cost: Money::new(cost, Currency::USD),
|
||||
salvage_value: Money::new(salvage, Currency::USD),
|
||||
useful_life_months: life,
|
||||
@@ -299,7 +300,7 @@ proptest! {
|
||||
expense_account: ex,
|
||||
}).unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
|
||||
let far = asset.in_service_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
|
||||
let sched = depreciation_schedule(asset, far).unwrap();
|
||||
// The lifetime total is exactly cost minus salvage: never a penny off.
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
@@ -314,8 +315,8 @@ proptest! {
|
||||
}
|
||||
// Book value at any date matches the schedule.
|
||||
if let Some(last) = sched.last() {
|
||||
prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value);
|
||||
prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated);
|
||||
prop_assert_eq!(scheduled_book_value(asset, far).unwrap(), last.book_value);
|
||||
prop_assert_eq!(accumulated_scheduled_through(asset, far).unwrap(), last.accumulated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,7 +507,7 @@ mod p3 {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "prop".to_string(),
|
||||
purchase_date: start,
|
||||
in_service_date: start,
|
||||
cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD),
|
||||
salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD),
|
||||
useful_life_months: life,
|
||||
@@ -631,7 +632,7 @@ proptest! {
|
||||
let mut prev = Decimal::ZERO;
|
||||
for day in days {
|
||||
let s = depreciation_schedule(a, day).unwrap();
|
||||
let acc = accumulated_depreciation(a, day).unwrap();
|
||||
let acc = accumulated_scheduled_through(a, day).unwrap();
|
||||
prop_assert_eq!(s.iter().map(|l| l.amount).sum::<Decimal>(), acc);
|
||||
prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO));
|
||||
prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev);
|
||||
@@ -673,10 +674,10 @@ proptest! {
|
||||
let full = depreciation_schedule(a, NaiveDate::MAX).unwrap();
|
||||
prop_assert_eq!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated);
|
||||
prop_assert_eq!(
|
||||
accumulated_depreciation(a, on + chrono::Days::new(later)).unwrap(),
|
||||
accumulated_scheduled_through(a, on + chrono::Days::new(later)).unwrap(),
|
||||
want.accumulated
|
||||
);
|
||||
prop_assert_eq!(book_value(a, on).unwrap(), want.book_value);
|
||||
prop_assert_eq!(scheduled_book_value(a, on).unwrap(), want.book_value);
|
||||
}
|
||||
|
||||
/// B-15 rule 3 (defect 22), against arithmetic written here: through
|
||||
@@ -711,7 +712,7 @@ proptest! {
|
||||
x / y + i128::from(2 * (x % y) >= y)
|
||||
};
|
||||
prop_assert_eq!(
|
||||
accumulated_depreciation(a, through).unwrap(),
|
||||
accumulated_scheduled_through(a, through).unwrap(),
|
||||
Decimal::new(want as i64, 2),
|
||||
"{} of {}", through, last_day
|
||||
);
|
||||
@@ -882,3 +883,53 @@ proptest! {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arb_basis_rule() -> impl Strategy<Value = ledger_core::BasisRule> {
|
||||
prop_oneof![
|
||||
Just(ledger_core::BasisRule::Purchase),
|
||||
Just(ledger_core::BasisRule::Carryover723),
|
||||
Just(ledger_core::BasisRule::PersonalUseConversion),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(5000))]
|
||||
|
||||
/// kernel::basis_figures INVARIANTS 1-4 on Decimal money (rules.md
|
||||
/// B-25): a purchase depreciates from cost and has no §723 basis or
|
||||
/// built-in gain; a contribution's §723 basis is the adjusted basis,
|
||||
/// its built-in gain fair market value less that basis, negative
|
||||
/// exactly when the basis exceeds the value; carryover depreciates
|
||||
/// from the basis, personal use from the lesser (at most each, equal
|
||||
/// to one).
|
||||
#[test]
|
||||
fn prop_basis_figures_laws(
|
||||
rule in arb_basis_rule(),
|
||||
cost in 1i64..=100_000_000_000,
|
||||
adjusted in 0i64..=100_000_000_000,
|
||||
fmv in 0i64..=100_000_000_000,
|
||||
) {
|
||||
use ledger_core::BasisRule::*;
|
||||
let (cost, adjusted, fmv) = (Decimal::new(cost, 2), Decimal::new(adjusted, 2), Decimal::new(fmv, 2));
|
||||
let f = ledger_core::kernel::basis_figures(rule, cost, adjusted, fmv).unwrap();
|
||||
match rule {
|
||||
Purchase => {
|
||||
prop_assert_eq!(f.depreciable_basis, cost);
|
||||
prop_assert_eq!(f.tax_basis_723, None);
|
||||
prop_assert_eq!(f.built_in_gain_704c, None);
|
||||
}
|
||||
Carryover723 | PersonalUseConversion => {
|
||||
prop_assert_eq!(f.tax_basis_723, Some(adjusted));
|
||||
let g = f.built_in_gain_704c.unwrap();
|
||||
prop_assert_eq!(g, fmv - adjusted);
|
||||
prop_assert_eq!(g < Decimal::ZERO, adjusted > fmv);
|
||||
if rule == Carryover723 {
|
||||
prop_assert_eq!(f.depreciable_basis, adjusted);
|
||||
} else {
|
||||
prop_assert!(f.depreciable_basis <= adjusted && f.depreciable_basis <= fmv);
|
||||
prop_assert!(f.depreciable_basis == adjusted || f.depreciable_basis == fmv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 905ded9e8e77bbcda0f9dfa53feb4e5279611111e7de70f9ab53d8b2ff6040ba # shrinks to assets = [(0.01, 0.00, 0.01, 2)]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@
|
||||
//! `AssetRegister`, and `docs/spec/rules.md` B-15 and B-24. Depreciation
|
||||
//! amounts are worked by hand (straight line, whole months) or, for
|
||||
//! mid-month dates, taken from the already-tested building block
|
||||
//! `accumulated_depreciation`. Nothing is read back from the code under
|
||||
//! `accumulated_scheduled_through`. Nothing is read back from the code under
|
||||
//! test and then asserted.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -21,7 +21,7 @@ use ledger_core::assets::{
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
||||
accumulated_depreciation, fixed_asset_register,
|
||||
accumulated_scheduled_through, fixed_asset_register,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -102,7 +102,7 @@ fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) {
|
||||
fn equipment(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset {
|
||||
NewAsset {
|
||||
description: description.to_string(),
|
||||
purchase_date: on,
|
||||
in_service_date: on,
|
||||
cost: usd(cost),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: life,
|
||||
@@ -715,6 +715,22 @@ fn depreciate(l: &mut Ledger, a: &Accts, accum: AccountId, on: NaiveDate, amt: &
|
||||
);
|
||||
}
|
||||
|
||||
/// One asset's depreciation run for the month of `on`, as a store posts
|
||||
/// and records it: Dr depreciation expense / Cr its accumulated
|
||||
/// depreciation, then the run (Ledger::record_depreciation_run).
|
||||
fn run(l: &mut Ledger, a: &Accts, asset: AssetId, on: NaiveDate, amt: &str) {
|
||||
let accum = l.get_asset(asset).unwrap().accum_depr_account;
|
||||
let e = l.try_create_entry(on, "depreciation").unwrap();
|
||||
l.add_line(e, a.depr_expense, Side::Debit, usd(amt))
|
||||
.unwrap();
|
||||
l.add_line(e, accum, Side::Credit, usd(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
let period = on.format("%Y-%m").to_string();
|
||||
l.check_depreciation_run(asset, &period, e, d(amt)).unwrap();
|
||||
l.record_depreciation_run(asset, &period, e, d(amt))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Buy equipment for cash: Dr cost account / Cr 1000.
|
||||
fn buy(l: &mut Ledger, a: &Accts, account: AccountId, on: NaiveDate, amt: &str) {
|
||||
post(
|
||||
@@ -747,7 +763,8 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) {
|
||||
.register_asset(equipment(&a, "Server", "2400.00", 24, date(2026, 1, 1)))
|
||||
.unwrap();
|
||||
for m in 1..=3 {
|
||||
depreciate(&mut l, &a, a.accum, month_end(m), "1100.00");
|
||||
run(&mut l, &a, laptop, month_end(m), "1000.00");
|
||||
run(&mut l, &a, server, month_end(m), "100.00");
|
||||
}
|
||||
(l, a, laptop, server)
|
||||
}
|
||||
@@ -755,22 +772,27 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) {
|
||||
/// INVARIANT 3: totals are the sums over held (not disposed) rows.
|
||||
fn assert_totals_are_sums(r: &ledger_core::AssetRegister) {
|
||||
let held = r.rows.iter().filter(|x| x.disposed.is_none());
|
||||
let (mut c, mut acc, mut bv) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
let (mut c, mut acc, mut posted, mut bv) =
|
||||
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
for x in held {
|
||||
c += x.cost;
|
||||
acc += x.accumulated;
|
||||
acc += x.accumulated_scheduled;
|
||||
posted += x.accumulated_posted;
|
||||
bv += x.book_value;
|
||||
}
|
||||
assert_eq!(r.total_cost, c);
|
||||
assert_eq!(r.total_accumulated, acc);
|
||||
assert_eq!(r.total_accumulated_scheduled, acc);
|
||||
assert_eq!(r.total_accumulated_posted, posted);
|
||||
assert_eq!(r.total_book_value, bv);
|
||||
for x in &r.rows {
|
||||
// INVARIANT 2 (held) / INVARIANT 1 (disposed).
|
||||
if x.disposed.is_some() {
|
||||
assert_eq!(x.accumulated, Decimal::ZERO);
|
||||
assert_eq!(x.accumulated_scheduled, Decimal::ZERO);
|
||||
assert_eq!(x.accumulated_posted, Decimal::ZERO);
|
||||
assert_eq!(x.book_value, Decimal::ZERO);
|
||||
} else {
|
||||
assert_eq!(x.book_value, x.cost - x.accumulated);
|
||||
// Book value is what the books carry: cost less posted.
|
||||
assert_eq!(x.book_value, x.cost - x.accumulated_posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,42 +812,52 @@ fn register_agrees_with_the_books_when_depreciation_is_posted() {
|
||||
assert_eq!(x.tag.as_deref(), Some("FL-0001"));
|
||||
assert_eq!(x.serial.as_deref(), Some("C02XG0FDJGH5"));
|
||||
assert_eq!(x.description, "Laptop");
|
||||
assert_eq!(x.in_service, date(2026, 1, 1));
|
||||
assert_eq!(x.in_service_date, date(2026, 1, 1));
|
||||
assert_eq!(x.cost, d("12000.00"));
|
||||
assert_eq!(x.salvage, d("0.00"));
|
||||
assert_eq!(x.useful_life_months, 12);
|
||||
assert_eq!(x.accumulated, d("3000.00"));
|
||||
assert_eq!(x.accumulated_scheduled, d("3000.00"));
|
||||
assert_eq!(x.accumulated_posted, d("3000.00"));
|
||||
assert_eq!(x.book_value, d("9000.00"));
|
||||
assert_eq!(x.disposed, None);
|
||||
let y = &r.rows[1];
|
||||
assert_eq!(y.tag, None);
|
||||
assert_eq!(y.serial, None);
|
||||
assert_eq!(y.accumulated, d("300.00"));
|
||||
assert_eq!(y.accumulated_scheduled, d("300.00"));
|
||||
assert_eq!(y.accumulated_posted, d("300.00"));
|
||||
assert_eq!(y.book_value, d("2100.00"));
|
||||
assert_eq!(r.total_cost, d("14400.00"));
|
||||
assert_eq!(r.total_accumulated, d("3300.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("3300.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("3300.00"));
|
||||
assert_eq!(r.total_book_value, d("11100.00"));
|
||||
// INVARIANT 4: the GL, each shared account counted once.
|
||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("3300.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
|
||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
|
||||
/// unposted_depreciation: due by schedule but not in the books.
|
||||
/// unposted_depreciation: due by schedule but not in the books. Book
|
||||
/// value stays what the books carry (cost less March's posted figure),
|
||||
/// never the schedule's April figure.
|
||||
#[test]
|
||||
fn register_shows_unposted_depreciation() {
|
||||
let (l, _, _, _) = register_fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 4, 30)).unwrap();
|
||||
assert_eq!(r.total_accumulated, d("4400.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("4400.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("3300.00"));
|
||||
assert_eq!(r.total_book_value, d("11100.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("3300.00"));
|
||||
assert_eq!(r.unposted_depreciation(), d("1100.00"));
|
||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
|
||||
/// Negative unposted_depreciation when the books carry more.
|
||||
/// Negative unposted_depreciation when the books carry more; a hand
|
||||
/// entry no asset's run accounts for is unattributed, and no asset's book
|
||||
/// value takes it.
|
||||
#[test]
|
||||
fn register_shows_overposted_depreciation_as_negative() {
|
||||
let (mut l, a, _, _) = register_fixture();
|
||||
@@ -833,6 +865,8 @@ fn register_shows_overposted_depreciation_as_negative() {
|
||||
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
|
||||
assert_eq!(r.ledger_accumulated, d("3350.00"));
|
||||
assert_eq!(r.unposted_depreciation(), d("-50.00"));
|
||||
assert_eq!(r.unattributed_accumulated(), d("50.00"));
|
||||
assert_eq!(r.total_book_value, d("11100.00"));
|
||||
}
|
||||
|
||||
/// The GL balances are on as_of: later postings don't count.
|
||||
@@ -840,25 +874,27 @@ fn register_shows_overposted_depreciation_as_negative() {
|
||||
fn register_ledger_balances_are_as_of_the_date() {
|
||||
let (l, _, _, _) = register_fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 2, 28)).unwrap();
|
||||
assert_eq!(r.total_accumulated, d("2200.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("2200.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("2200.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("2200.00"));
|
||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
/// INVARIANT 2 mid-month: accumulated is accumulated_depreciation through
|
||||
/// as_of.
|
||||
/// INVARIANT 2 mid-month: accumulated_scheduled is accumulated_scheduled_through
|
||||
/// through as_of; book value stays cost less what is posted.
|
||||
#[test]
|
||||
fn register_accumulated_mid_month_follows_the_schedule() {
|
||||
let (l, _, laptop, server) = register_fixture();
|
||||
let on = date(2026, 5, 17);
|
||||
let r = fixed_asset_register(&l, on).unwrap();
|
||||
let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on).unwrap();
|
||||
let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on).unwrap();
|
||||
assert_eq!(r.rows[0].accumulated, la);
|
||||
assert_eq!(r.rows[1].accumulated, sa);
|
||||
assert_eq!(r.rows[0].book_value, d("12000.00") - la);
|
||||
assert_eq!(r.total_accumulated, la + sa);
|
||||
let la = accumulated_scheduled_through(l.get_asset(laptop).unwrap(), on).unwrap();
|
||||
let sa = accumulated_scheduled_through(l.get_asset(server).unwrap(), on).unwrap();
|
||||
assert_eq!(r.rows[0].accumulated_scheduled, la);
|
||||
assert_eq!(r.rows[1].accumulated_scheduled, sa);
|
||||
assert_eq!(r.rows[0].accumulated_posted, d("3000.00"));
|
||||
assert_eq!(r.rows[0].book_value, d("9000.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, la + sa);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
|
||||
@@ -881,7 +917,8 @@ fn register_shows_unregistered_cost() {
|
||||
assert_eq!(r2.unposted_depreciation(), d("100.00"));
|
||||
}
|
||||
|
||||
/// INVARIANT 1: only assets registered (placed in service) by as_of.
|
||||
/// INVARIANT 1: only assets acquired by as_of (with no acquisition entry
|
||||
/// linked, the in-service date).
|
||||
#[test]
|
||||
fn register_leaves_out_assets_not_yet_in_service() {
|
||||
let (mut l, a, laptop, server) = register_fixture();
|
||||
@@ -897,14 +934,14 @@ fn register_leaves_out_assets_not_yet_in_service() {
|
||||
let ids: Vec<AssetId> = r.rows.iter().map(|x| x.asset_id).collect();
|
||||
assert_eq!(ids, vec![laptop, server, chair]);
|
||||
assert_eq!(
|
||||
r.rows[2].accumulated,
|
||||
accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
|
||||
r.rows[2].accumulated_scheduled,
|
||||
accumulated_scheduled_through(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
|
||||
);
|
||||
// Before anything: empty and zero.
|
||||
let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap();
|
||||
assert!(r.rows.is_empty());
|
||||
assert_eq!(r.total_cost, Decimal::ZERO);
|
||||
assert_eq!(r.total_accumulated, Decimal::ZERO);
|
||||
assert_eq!(r.total_accumulated_scheduled, Decimal::ZERO);
|
||||
assert_eq!(r.total_book_value, Decimal::ZERO);
|
||||
}
|
||||
|
||||
@@ -912,8 +949,14 @@ fn register_leaves_out_assets_not_yet_in_service() {
|
||||
/// depreciation: book value 1,800, gain 200. Entry: Dr 1000 2,000,
|
||||
/// Dr 1510 600, Cr 1500 2,400, Cr 4900 200.
|
||||
fn dispose_server(l: &mut Ledger, a: &Accts, server: AssetId) {
|
||||
let laptop = l
|
||||
.registered_assets()
|
||||
.find(|x| x.description == "Laptop")
|
||||
.unwrap()
|
||||
.id;
|
||||
for m in 4..=6 {
|
||||
depreciate(l, a, a.accum, month_end(m), "1100.00");
|
||||
run(l, a, laptop, month_end(m), "1000.00");
|
||||
run(l, a, server, month_end(m), "100.00");
|
||||
}
|
||||
l.dispose_asset(server, date(2026, 6, 30), usd("2000.00"))
|
||||
.unwrap();
|
||||
@@ -938,18 +981,22 @@ fn register_shows_a_disposed_asset_outside_the_totals() {
|
||||
assert_eq!(r.rows.len(), 2, "a disposed asset still has a row");
|
||||
let s = r.rows.iter().find(|x| x.asset_id == server).unwrap();
|
||||
assert_eq!(s.disposed, Some((date(2026, 6, 30), d("2000.00"))));
|
||||
assert_eq!(s.accumulated, Decimal::ZERO);
|
||||
assert_eq!(s.accumulated_scheduled, Decimal::ZERO);
|
||||
assert_eq!(s.accumulated_posted, Decimal::ZERO);
|
||||
assert_eq!(s.book_value, Decimal::ZERO);
|
||||
assert_eq!(s.tag.as_deref(), Some("FL-0002"));
|
||||
assert_eq!(s.cost, d("2400.00"));
|
||||
let lp = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
||||
assert_eq!(lp.accumulated, d("6000.00"));
|
||||
assert_eq!(lp.accumulated_scheduled, d("6000.00"));
|
||||
assert_eq!(lp.accumulated_posted, d("6000.00"));
|
||||
assert_eq!(r.total_cost, d("12000.00"));
|
||||
assert_eq!(r.total_accumulated, d("6000.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("6000.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("6000.00"));
|
||||
assert_eq!(r.total_book_value, d("6000.00"));
|
||||
assert_eq!(r.ledger_cost, d("12000.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("6000.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
|
||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
@@ -962,10 +1009,11 @@ fn register_before_the_disposal_date_shows_the_asset_held() {
|
||||
let r = fixed_asset_register(&l, date(2026, 5, 31)).unwrap();
|
||||
let s = r.rows.iter().find(|x| x.asset_id == server).unwrap();
|
||||
assert_eq!(s.disposed, None);
|
||||
assert_eq!(s.accumulated, d("500.00"));
|
||||
assert_eq!(s.accumulated_scheduled, d("500.00"));
|
||||
assert_eq!(s.accumulated_posted, d("500.00"));
|
||||
assert_eq!(s.book_value, d("1900.00"));
|
||||
assert_eq!(r.total_cost, d("14400.00"));
|
||||
assert_eq!(r.total_accumulated, d("5500.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("5500.00"));
|
||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("5500.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
@@ -977,27 +1025,29 @@ fn register_before_the_disposal_date_shows_the_asset_held() {
|
||||
fn register_sums_the_gl_over_distinct_asset_accounts() {
|
||||
let (mut l, a, _, _) = register_fixture();
|
||||
buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "36000.00");
|
||||
l.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("36000.00"),
|
||||
salvage_value: usd("6000.00"),
|
||||
useful_life_months: 60,
|
||||
cost_account: a.vehicles,
|
||||
accum_depr_account: a.vehicles_accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap();
|
||||
let van = l
|
||||
.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd("36000.00"),
|
||||
salvage_value: usd("6000.00"),
|
||||
useful_life_months: 60,
|
||||
cost_account: a.vehicles,
|
||||
accum_depr_account: a.vehicles_accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap();
|
||||
// (36,000 − 6,000) / 60 = 500/month; Jan–Mar posted.
|
||||
for m in 1..=3 {
|
||||
depreciate(&mut l, &a, a.vehicles_accum, month_end(m), "500.00");
|
||||
run(&mut l, &a, van, month_end(m), "500.00");
|
||||
}
|
||||
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
|
||||
assert_eq!(r.rows[2].salvage, d("6000.00"));
|
||||
assert_eq!(r.rows[2].accumulated, d("1500.00"));
|
||||
assert_eq!(r.rows[2].accumulated_scheduled, d("1500.00"));
|
||||
assert_eq!(r.rows[2].accumulated_posted, d("1500.00"));
|
||||
assert_eq!(r.rows[2].book_value, d("34500.00"));
|
||||
assert_eq!(r.total_cost, d("50400.00"));
|
||||
assert_eq!(r.total_accumulated, d("4800.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("4800.00"));
|
||||
assert_eq!(r.total_book_value, d("45600.00"));
|
||||
assert_eq!(r.ledger_cost, d("50400.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("4800.00"));
|
||||
@@ -1031,13 +1081,15 @@ fn register_of_no_assets_is_empty() {
|
||||
}
|
||||
|
||||
/// Defect 22 in the register (INVARIANT 2): an asset disposed of later in
|
||||
/// the same month as `as_of` is held on `as_of`, its accumulated
|
||||
/// depreciation prorated by days through `as_of`, never plugged to the
|
||||
/// whole. Hand check: the 12,000.00 laptop (12 months from 2026-01-01),
|
||||
/// disposed of 2026-06-20; on 2026-06-10, five months of 1,000.00 and
|
||||
/// June's 10/30: 5,333.33, book value 6,666.67. Before P3 the disposed
|
||||
/// the same month as `as_of` is held on `as_of`, its scheduled
|
||||
/// accumulated depreciation prorated by days through `as_of`, never
|
||||
/// plugged to the whole. Hand check: the 12,000.00 laptop (12 months from
|
||||
/// 2026-01-01), disposed of 2026-06-20; on 2026-06-10, five months of
|
||||
/// 1,000.00 and June's 10/30: 5,333.33 scheduled. Before P3 the disposed
|
||||
/// schedule ended in a plug, so the register showed 12,000.00 and 0.00.
|
||||
/// The same in the life's final month: on 2026-12-15, 11,483.87.
|
||||
/// The same in the life's final month: on 2026-12-15, 11,483.87. Book
|
||||
/// value is cost less what the books carry (defect 13, rules.md B-15 rule
|
||||
/// 8): the fixture's runs, January to March, 3,000.00, so 9,000.00.
|
||||
#[test]
|
||||
fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
||||
let (mut l, _, laptop, _) = register_fixture();
|
||||
@@ -1045,19 +1097,21 @@ fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
||||
.unwrap();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap();
|
||||
assert_eq!(r.rows[0].disposed, None);
|
||||
assert_eq!(r.rows[0].accumulated, d("5333.33"));
|
||||
assert_eq!(r.rows[0].book_value, d("6666.67"));
|
||||
assert_eq!(r.rows[0].accumulated_scheduled, d("5333.33"));
|
||||
// Book value is what the books carry: January to March's runs.
|
||||
assert_eq!(r.rows[0].accumulated_posted, d("3000.00"));
|
||||
assert_eq!(r.rows[0].book_value, d("9000.00"));
|
||||
assert_totals_are_sums(&r);
|
||||
// On the disposal day it is gone: no figures, in no total.
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 20)).unwrap();
|
||||
assert_eq!(r.rows[0].accumulated, Decimal::ZERO);
|
||||
assert_eq!(r.rows[0].accumulated_scheduled, Decimal::ZERO);
|
||||
assert!(r.rows[0].disposed.is_some());
|
||||
|
||||
let (l, _, laptop, _) = register_fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap();
|
||||
let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
||||
assert_eq!(
|
||||
(row.accumulated, row.book_value),
|
||||
(d("11483.87"), d("516.13"))
|
||||
(row.accumulated_scheduled, row.book_value),
|
||||
(d("11483.87"), d("9000.00"))
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ use ledger_core::payroll::{
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Asset, AssetId, AssetStatus, Currency, DepreciationMethod, EntryId,
|
||||
Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_depreciation, book_value,
|
||||
depreciation_schedule, disposal_amounts,
|
||||
Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_scheduled_through,
|
||||
depreciation_schedule, disposal_amounts, scheduled_book_value,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -49,7 +49,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
||||
Asset {
|
||||
id: 1,
|
||||
description: "Test asset".to_string(),
|
||||
purchase_date: purchase,
|
||||
in_service_date: purchase,
|
||||
cost: usd(cost),
|
||||
salvage_value: usd(salvage),
|
||||
useful_life_months: life,
|
||||
@@ -61,7 +61,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
||||
tag: None,
|
||||
serial: None,
|
||||
acquired_by: None,
|
||||
tax: None,
|
||||
basis: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fn fixture() -> (Ledger, Accts) {
|
||||
fn new_asset(a: &Accts) -> NewAsset {
|
||||
NewAsset {
|
||||
description: "Laptop".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd("12000.00"),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 12,
|
||||
@@ -377,9 +377,12 @@ fn salvage_value_is_never_depreciated_b15() {
|
||||
let amounts: Vec<Decimal> = s.iter().map(|l| l.amount).collect();
|
||||
assert_eq!(amounts, vec![d("1000.00"); 4]);
|
||||
assert_eq!(s.last().unwrap().book_value, d("1000.00"));
|
||||
assert_eq!(book_value(&a, date(2040, 1, 31)).unwrap(), d("1000.00"));
|
||||
assert_eq!(
|
||||
accumulated_depreciation(&a, date(2040, 1, 31)).unwrap(),
|
||||
scheduled_book_value(&a, date(2040, 1, 31)).unwrap(),
|
||||
d("1000.00")
|
||||
);
|
||||
assert_eq!(
|
||||
accumulated_scheduled_through(&a, date(2040, 1, 31)).unwrap(),
|
||||
d("4000.00")
|
||||
);
|
||||
}
|
||||
@@ -418,10 +421,13 @@ fn nothing_depreciates_before_purchase() {
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
accumulated_depreciation(&a, date(2025, 12, 31)).unwrap(),
|
||||
accumulated_scheduled_through(&a, date(2025, 12, 31)).unwrap(),
|
||||
d("0")
|
||||
);
|
||||
assert_eq!(book_value(&a, date(2025, 12, 31)).unwrap(), d("12000.00"));
|
||||
assert_eq!(
|
||||
scheduled_book_value(&a, date(2025, 12, 31)).unwrap(),
|
||||
d("12000.00")
|
||||
);
|
||||
}
|
||||
|
||||
/// Oracle: interface docs "accumulated through as_of" and "book value =
|
||||
@@ -430,10 +436,13 @@ fn nothing_depreciates_before_purchase() {
|
||||
fn accumulated_and_book_value_mid_life() {
|
||||
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||
assert_eq!(
|
||||
accumulated_depreciation(&a, date(2026, 6, 30)).unwrap(),
|
||||
accumulated_scheduled_through(&a, date(2026, 6, 30)).unwrap(),
|
||||
d("6000.00")
|
||||
);
|
||||
assert_eq!(
|
||||
scheduled_book_value(&a, date(2026, 6, 30)).unwrap(),
|
||||
d("6000.00")
|
||||
);
|
||||
assert_eq!(book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00"));
|
||||
}
|
||||
|
||||
// ------------------------------- defect 22: the final month, disposals
|
||||
@@ -454,7 +463,10 @@ fn defect22_depreciation_through_mid_final_month_is_prorated_not_plugged() {
|
||||
assert_eq!(dec.amount, d("483.87"));
|
||||
assert_eq!(dec.accumulated, d("11483.87"));
|
||||
assert_eq!(dec.book_value, d("516.13"));
|
||||
assert_eq!(book_value(&a, date(2026, 12, 15)).unwrap(), d("516.13"));
|
||||
assert_eq!(
|
||||
scheduled_book_value(&a, date(2026, 12, 15)).unwrap(),
|
||||
d("516.13")
|
||||
);
|
||||
// Through its last day, the whole of it.
|
||||
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
|
||||
assert_eq!(
|
||||
@@ -531,12 +543,12 @@ fn defect22_a_disposed_assets_schedule_stops_at_its_disposal_day() {
|
||||
);
|
||||
assert_eq!(jun.accumulated, before.accumulated);
|
||||
assert_eq!(
|
||||
accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
||||
accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||
d("5500.00")
|
||||
);
|
||||
// A through-date before the disposal is unchanged by it.
|
||||
assert_eq!(
|
||||
accumulated_depreciation(a, date(2026, 5, 31)).unwrap(),
|
||||
accumulated_scheduled_through(a, date(2026, 5, 31)).unwrap(),
|
||||
d("5000.00")
|
||||
);
|
||||
|
||||
@@ -680,12 +692,13 @@ fn disposal_refuses_negative_proceeds() {
|
||||
/// Oracle: LedgerError::DisposalBeforePurchase doc "disposal date must not
|
||||
/// precede the purchase date".
|
||||
#[test]
|
||||
fn disposal_refuses_date_before_purchase() {
|
||||
fn disposal_before_service_accrues_nothing() {
|
||||
// The floor is the acquired date, the ledger's to check
|
||||
// (Ledger::dispose_asset); the math alone has nothing accrued yet.
|
||||
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||
assert!(matches!(
|
||||
disposal_amounts(&a, date(2025, 12, 31), usd("100.00")),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
let got = disposal_amounts(&a, date(2025, 12, 31), usd("100.00")).unwrap();
|
||||
assert_eq!(got.accumulated, d("0"));
|
||||
assert_eq!(got.gain_loss, d("-11900.00"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- register / dispose
|
||||
@@ -702,7 +715,7 @@ fn register_asset_records_inputs_under_next_id() {
|
||||
assert_eq!(id, expected_id);
|
||||
let got = l.get_asset(id).unwrap();
|
||||
assert_eq!(got.description, "Laptop");
|
||||
assert_eq!(got.purchase_date, date(2026, 1, 1));
|
||||
assert_eq!(got.in_service_date, date(2026, 1, 1));
|
||||
assert_eq!(got.cost, usd("12000.00"));
|
||||
assert_eq!(got.salvage_value, usd("0.00"));
|
||||
assert_eq!(got.useful_life_months, 12);
|
||||
@@ -900,7 +913,7 @@ fn dispose_asset_refuses_bad_disposals_unchanged() {
|
||||
);
|
||||
assert!(matches!(
|
||||
l.dispose_asset(id, date(2025, 12, 1), usd("1.00")),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
Err(LedgerError::DisposalBeforeAcquisition { .. })
|
||||
));
|
||||
assert_eq!(snapshot(&l), before);
|
||||
}
|
||||
@@ -1317,7 +1330,7 @@ proptest! {
|
||||
prop_assert_eq!(total, cost - salvage);
|
||||
let life = a.useful_life_months as usize;
|
||||
prop_assert!(s.len() == life || s.len() == life + 1, "len {} life {}", s.len(), life);
|
||||
prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.purchase_date.year(), a.purchase_date.month()));
|
||||
prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.in_service_date.year(), a.in_service_date.month()));
|
||||
let mut acc = Decimal::ZERO;
|
||||
let mut prev_bv = cost;
|
||||
for (i, l) in s.iter().enumerate() {
|
||||
@@ -1336,7 +1349,7 @@ proptest! {
|
||||
prop_assert_eq!(s.last().unwrap().book_value, salvage);
|
||||
}
|
||||
|
||||
/// Oracle: accumulated_depreciation / book_value docs agree with the
|
||||
/// Oracle: accumulated_scheduled_through / book_value docs agree with the
|
||||
/// schedule at each month end, a shorter schedule is a prefix of the full
|
||||
/// one, and nothing is depreciated before purchase.
|
||||
#[test]
|
||||
@@ -1344,14 +1357,14 @@ proptest! {
|
||||
let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
|
||||
for (i, l) in full.iter().enumerate() {
|
||||
let end = period_end(&l.period);
|
||||
prop_assert_eq!(accumulated_depreciation(&a, end).unwrap(), l.accumulated);
|
||||
prop_assert_eq!(book_value(&a, end).unwrap(), l.book_value);
|
||||
prop_assert_eq!(accumulated_scheduled_through(&a, end).unwrap(), l.accumulated);
|
||||
prop_assert_eq!(scheduled_book_value(&a, end).unwrap(), l.book_value);
|
||||
let prefix = depreciation_schedule(&a, end).unwrap();
|
||||
prop_assert_eq!(&prefix[..], &full[..=i]);
|
||||
}
|
||||
let before = a.purchase_date.pred_opt().unwrap();
|
||||
prop_assert_eq!(accumulated_depreciation(&a, before).unwrap(), Decimal::ZERO);
|
||||
prop_assert_eq!(book_value(&a, before).unwrap(), a.cost.amount);
|
||||
let before = a.in_service_date.pred_opt().unwrap();
|
||||
prop_assert_eq!(accumulated_scheduled_through(&a, before).unwrap(), Decimal::ZERO);
|
||||
prop_assert_eq!(scheduled_book_value(&a, before).unwrap(), a.cost.amount);
|
||||
}
|
||||
|
||||
/// Oracle: B-15 partial months by days + interface "the last month absorbs
|
||||
@@ -1386,12 +1399,12 @@ proptest! {
|
||||
offset in 0u32..=200,
|
||||
proceeds in 0i64..=20_000_000,
|
||||
) {
|
||||
let when = month_end(a.purchase_date.checked_add_months(Months::new(offset)).unwrap());
|
||||
let when = month_end(a.in_service_date.checked_add_months(Months::new(offset)).unwrap());
|
||||
let pr = Decimal::new(proceeds, 2);
|
||||
let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap();
|
||||
prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when).unwrap());
|
||||
prop_assert_eq!(got.accumulated, accumulated_scheduled_through(&a, when).unwrap());
|
||||
prop_assert_eq!(got.book_value, a.cost.amount - got.accumulated);
|
||||
prop_assert_eq!(got.book_value, book_value(&a, when).unwrap());
|
||||
prop_assert_eq!(got.book_value, scheduled_book_value(&a, when).unwrap());
|
||||
prop_assert_eq!(got.gain_loss, pr - got.book_value);
|
||||
prop_assert!(got.book_value >= a.salvage_value.amount);
|
||||
}
|
||||
|
||||
@@ -230,9 +230,9 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
let equipment = add("1500", AccountType::Asset);
|
||||
let accum = add("1501", AccountType::Asset);
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
let asset = |purchase_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset {
|
||||
let asset = |in_service_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset {
|
||||
description: "Late".into(),
|
||||
purchase_date,
|
||||
in_service_date,
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months,
|
||||
@@ -267,11 +267,11 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
.ledger
|
||||
.replay_register_asset(asset(d(2026, 1, 1), 12))
|
||||
.unwrap();
|
||||
let fix = |purchase_date: NaiveDate| ledger_core::AssetCorrection {
|
||||
let fix = |in_service_date: NaiveDate| ledger_core::AssetCorrection {
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 60,
|
||||
purchase_date,
|
||||
in_service_date,
|
||||
};
|
||||
assert_eq!(
|
||||
b.ledger.correct_asset(id, fix(d(9999, 1, 1))).unwrap_err(),
|
||||
@@ -280,7 +280,10 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
b.ledger
|
||||
.replay_correct_asset(id, fix(d(9999, 1, 1)))
|
||||
.unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().purchase_date, d(9999, 1, 1));
|
||||
assert_eq!(
|
||||
b.ledger.get_asset(id).unwrap().in_service_date,
|
||||
d(9999, 1, 1)
|
||||
);
|
||||
// A disposal dated past the calendar.
|
||||
let proceeds = usd(Decimal::ZERO);
|
||||
assert_eq!(
|
||||
@@ -467,18 +470,36 @@ fn amounts_larger_than_a_line_are_refused_everywhere() {
|
||||
ledger_core::payout_plan(&payout(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT), b.ledger.chart())
|
||||
.is_ok()
|
||||
);
|
||||
// A contributor's basis.
|
||||
let basis = |b: Decimal| ledger_core::TaxBasis {
|
||||
rule: ledger_core::TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(b),
|
||||
// A contributor's basis and a value at contribution.
|
||||
let basis = |b: Decimal, v: Decimal| {
|
||||
ledger_core::AssetBasis::contribution(ledger_core::BasisRule::Carryover723, b, v)
|
||||
};
|
||||
assert!(basis(big).check().is_err());
|
||||
assert!(basis(MAX_LINE_AMOUNT).check().is_ok());
|
||||
// On replay: every journaled basis passed check_journaled.
|
||||
assert!(basis(big, Decimal::ONE).check().is_err());
|
||||
assert!(basis(Decimal::ONE, big).check().is_err());
|
||||
assert!(basis(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT).check().is_ok());
|
||||
// On replay: every journaled basis passed check_journaled (a pre-P3
|
||||
// one check_legacy_journaled).
|
||||
let cents_max = Decimal::MAX.round_dp(2) / Decimal::ONE_HUNDRED;
|
||||
assert!(basis(big.round_dp(2)).check_journaled().is_ok());
|
||||
assert!(basis(cents_max.round_dp(2)).check_journaled().is_ok());
|
||||
assert!(basis(-Decimal::ONE).check_journaled().is_err());
|
||||
assert!(
|
||||
basis(big.round_dp(2), big.round_dp(2))
|
||||
.check_journaled()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
basis(cents_max.round_dp(2), Decimal::ONE)
|
||||
.check_journaled()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
basis(-Decimal::ONE, Decimal::ONE)
|
||||
.check_journaled()
|
||||
.is_err()
|
||||
);
|
||||
let mut legacy = basis(big.round_dp(2), Decimal::ONE);
|
||||
legacy.fmv_at_contribution = None;
|
||||
assert!(legacy.check_legacy_journaled().is_ok());
|
||||
legacy.contributor_adjusted_basis = Some(-Decimal::ONE);
|
||||
assert!(legacy.check_legacy_journaled().is_err());
|
||||
// A party's share of an entry.
|
||||
let share = |a: Decimal| {
|
||||
vec![ledger_core::PartyShare {
|
||||
@@ -508,16 +529,19 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
|
||||
// Decimal's largest is a whole number of cents, as every version
|
||||
// required of a basis and a share.
|
||||
let cents_max = Decimal::MAX;
|
||||
let basis = ledger_core::TaxBasis {
|
||||
rule: ledger_core::TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(cents_max),
|
||||
let basis = ledger_core::AssetBasis {
|
||||
rule: ledger_core::BasisRule::Carryover723,
|
||||
contributor_adjusted_basis: Some(cents_max),
|
||||
fmv_at_contribution: None,
|
||||
sec704c_method: None,
|
||||
source: None,
|
||||
};
|
||||
for _ in 0..2 {
|
||||
let id = b
|
||||
.ledger
|
||||
.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,
|
||||
@@ -526,12 +550,16 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
|
||||
expense_account: depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
let mut new = basis.clone();
|
||||
new.fmv_at_contribution = Some(Decimal::new(240000, 2));
|
||||
assert!(matches!(
|
||||
b.ledger.set_asset_tax_basis(id, basis),
|
||||
b.ledger.set_asset_basis(id, new),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
b.ledger.replay_set_asset_tax_basis(id, basis).unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().tax, Some(basis));
|
||||
b.ledger
|
||||
.replay_set_asset_tax_basis(id, basis.rule, basis.contributor_adjusted_basis)
|
||||
.unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().basis, Some(basis.clone()));
|
||||
}
|
||||
assert!(matches!(
|
||||
ledger_core::fixed_asset_register(&b.ledger, d(2026, 12, 31)),
|
||||
@@ -630,7 +658,7 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
let laptop = || 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,
|
||||
@@ -672,8 +700,11 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
||||
b.ledger.get_asset(old).unwrap().status,
|
||||
ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX
|
||||
));
|
||||
// Replay keeps what every version checked: no disposal twice, none
|
||||
// before the purchase, no negative proceeds.
|
||||
// Replay keeps what every version checked: no disposal twice, no
|
||||
// negative proceeds. The date floor (the acquired date, P3) is a
|
||||
// command's: replay accepts a disposal before it, which every earlier
|
||||
// version's commands refused anyway (rules.md B-28: replay accepts
|
||||
// more, never less).
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE))
|
||||
@@ -681,16 +712,19 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
||||
LedgerError::AssetAlreadyDisposed(old)
|
||||
);
|
||||
let early = b.ledger.register_asset(laptop()).unwrap();
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)),
|
||||
Err(LedgerError::NegativeProceeds(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)),
|
||||
Err(LedgerError::DisposalBeforeAcquisition { .. })
|
||||
));
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The bounds are the line limit exactly: a new draft line (base or
|
||||
@@ -808,12 +842,12 @@ fn new_asset(
|
||||
accounts: (AccountId, AccountId, AccountId),
|
||||
cost: &str,
|
||||
salvage: &str,
|
||||
purchase_date: NaiveDate,
|
||||
in_service_date: NaiveDate,
|
||||
life: u32,
|
||||
) -> ledger_core::NewAsset {
|
||||
ledger_core::NewAsset {
|
||||
description: "Machine".into(),
|
||||
purchase_date,
|
||||
in_service_date,
|
||||
cost: usd(cost.parse().unwrap()),
|
||||
salvage_value: usd(salvage.parse().unwrap()),
|
||||
useful_life_months: life,
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Books {
|
||||
self.l
|
||||
.register_asset(NewAsset {
|
||||
description: format!("Asset on {cost}"),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
in_service_date: d(2026, 1, 1),
|
||||
cost: Money::new(x(amount), Currency::USD),
|
||||
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
|
||||
useful_life_months: 36,
|
||||
|
||||
@@ -46,7 +46,7 @@ fn depreciation_reaches_chronos_last_month() {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: "edge".into(),
|
||||
purchase_date: d(y, 1, 15),
|
||||
in_service_date: d(y, 1, 15),
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 11,
|
||||
@@ -83,11 +83,11 @@ fn depreciation_reaches_chronos_last_month() {
|
||||
assert_eq!(want, Decimal::new(118592, 2));
|
||||
assert_eq!(dec.last().unwrap().accumulated, want);
|
||||
assert_eq!(
|
||||
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
||||
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||
Decimal::new(120000, 2)
|
||||
);
|
||||
assert_eq!(
|
||||
ledger_core::book_value(a, NaiveDate::MAX).unwrap(),
|
||||
ledger_core::scheduled_book_value(a, NaiveDate::MAX).unwrap(),
|
||||
Decimal::ZERO
|
||||
);
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn a_life_past_9999_trues_up_to_the_cent() {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: "far".into(),
|
||||
purchase_date: start,
|
||||
in_service_date: start,
|
||||
cost: usd(Decimal::new(100_000, 2)),
|
||||
salvage_value: usd(Decimal::new(1, 2)),
|
||||
useful_life_months: months,
|
||||
@@ -172,7 +172,7 @@ fn february_is_prorated_by_chronos_calendar_in_every_year() {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: format!("{year}"),
|
||||
purchase_date: d(year, 2, 1),
|
||||
in_service_date: d(year, 2, 1),
|
||||
cost: usd(Decimal::from(2900)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 2,
|
||||
|
||||
@@ -1523,7 +1523,7 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
|
||||
);
|
||||
co.l.register_asset(NewAsset {
|
||||
description: "Laptop".into(),
|
||||
purchase_date: d(2026, 2, 1),
|
||||
in_service_date: d(2026, 2, 1),
|
||||
cost: usd(120000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 12,
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
//! Spec-derived tests for an asset's federal tax basis (rules.md B-25):
|
||||
//! `TaxBasisRule`, `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`,
|
||||
//! `Asset::tax_basis`, `Asset::built_in_gain`, and the tax fields of
|
||||
//! `reports::fixed_asset_register`.
|
||||
//!
|
||||
//! Every expected value comes from the written specification: the doc
|
||||
//! comments and INVARIANTS/SCENARIOS blocks on those items, and the
|
||||
//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost
|
||||
//! basis (IRC §1012(a)), carryover of the contributor's adjusted basis
|
||||
//! (IRC §723), the lesser of fair market value and adjusted basis on a
|
||||
//! conversion from personal use (Treas. Reg. §1.168(i)-4(b)(1)), and
|
||||
//! built-in gain or loss as fair market value less the contributor's basis
|
||||
//! (IRC §704(c)). Amounts are worked by hand.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
||||
TaxBasis, TaxBasisRule, fixed_asset_register,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).expect("decimal literal")
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(d(s), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
|
||||
}
|
||||
|
||||
struct Accts {
|
||||
equipment: AccountId,
|
||||
accum: AccountId,
|
||||
depr_expense: AccountId,
|
||||
}
|
||||
|
||||
/// A USD ledger with a fixed-asset chart, funded with $50,000 cash.
|
||||
fn fixture() -> (Ledger, Accts) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut acct = |code: &str, name: &str, ty: AccountType| {
|
||||
l.add_account(code, name, ty, None, false).unwrap()
|
||||
};
|
||||
let cash = acct("1000", "Cash", AccountType::Asset);
|
||||
let equipment = acct("1500", "Equipment", AccountType::Asset);
|
||||
let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset);
|
||||
let capital = acct("3000", "Owner Capital", AccountType::Equity);
|
||||
let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense);
|
||||
let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap();
|
||||
l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap();
|
||||
l.add_line(e, capital, Side::Credit, usd("50000.00"))
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
(
|
||||
l,
|
||||
Accts {
|
||||
equipment,
|
||||
accum,
|
||||
depr_expense,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId {
|
||||
l.register_asset(NewAsset {
|
||||
description: description.to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd(cost),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 36,
|
||||
cost_account: a.equipment,
|
||||
accum_depr_account: a.accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn tb(rule: TaxBasisRule, basis: Option<&str>) -> TaxBasis {
|
||||
TaxBasis {
|
||||
rule,
|
||||
contributor_basis: basis.map(d),
|
||||
}
|
||||
}
|
||||
|
||||
/// A ledger with one asset of book cost (fair market value) `cost`.
|
||||
fn one(cost: &str) -> (Ledger, AssetId) {
|
||||
let (mut l, a) = fixture();
|
||||
let id = register(&mut l, &a, "Laptop", cost);
|
||||
(l, id)
|
||||
}
|
||||
|
||||
fn set(l: &mut Ledger, id: AssetId, rule: TaxBasisRule, basis: Option<&str>) {
|
||||
l.set_asset_tax_basis(id, tb(rule, basis)).unwrap();
|
||||
}
|
||||
|
||||
const RULES: [TaxBasisRule; 3] = [
|
||||
TaxBasisRule::Cost,
|
||||
TaxBasisRule::Carryover,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- TaxBasisRule
|
||||
|
||||
/// TaxBasisRule::key: the wire names `cost`, `carryover`,
|
||||
/// `personal_use_conversion`.
|
||||
#[test]
|
||||
fn rule_keys_are_the_documented_wire_names() {
|
||||
assert_eq!(TaxBasisRule::Cost.key(), "cost");
|
||||
assert_eq!(TaxBasisRule::Carryover.key(), "carryover");
|
||||
assert_eq!(
|
||||
TaxBasisRule::PersonalUseConversion.key(),
|
||||
"personal_use_conversion"
|
||||
);
|
||||
}
|
||||
|
||||
/// TaxBasisRule::parse: the rule named `key`; round-trips with key().
|
||||
#[test]
|
||||
fn rule_parse_round_trips_with_key() {
|
||||
for r in RULES {
|
||||
assert_eq!(TaxBasisRule::parse(r.key()), Some(r));
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything other than a documented wire name is no rule.
|
||||
#[test]
|
||||
fn rule_parse_refuses_other_names() {
|
||||
for bad in [
|
||||
"",
|
||||
"Cost",
|
||||
"COST",
|
||||
" cost",
|
||||
"cost ",
|
||||
"carry_over",
|
||||
"personal-use-conversion",
|
||||
"PersonalUseConversion",
|
||||
"fmv",
|
||||
] {
|
||||
assert_eq!(TaxBasisRule::parse(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The serde names are snake_case, the same as the wire names.
|
||||
#[test]
|
||||
fn rule_serde_names_are_the_wire_names() {
|
||||
for r in RULES {
|
||||
let j = serde_json::to_value(r).unwrap();
|
||||
assert_eq!(j, serde_json::Value::String(r.key().to_string()));
|
||||
let back: TaxBasisRule = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back, r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- set / check
|
||||
|
||||
/// SCENARIO 1: an unknown asset is refused with UnknownAsset.
|
||||
#[test]
|
||||
fn an_unknown_asset_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
let unknown = id + 99;
|
||||
for t in [
|
||||
tb(TaxBasisRule::Cost, None),
|
||||
tb(TaxBasisRule::Carryover, Some("500.00")),
|
||||
] {
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(unknown, t),
|
||||
Err(LedgerError::UnknownAsset(x)) if x == unknown
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(unknown, t),
|
||||
Err(LedgerError::UnknownAsset(x)) if x == unknown
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Cost takes no contributor basis.
|
||||
#[test]
|
||||
fn cost_with_a_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for b in ["0.00", "500.00", "1000.00"] {
|
||||
let t = tb(TaxBasisRule::Cost, Some(b));
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
assert_eq!(l.get_asset(id).unwrap().tax, None);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Carryover and PersonalUseConversion require one.
|
||||
#[test]
|
||||
fn a_contribution_without_a_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
let t = tb(r, None);
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a contributor basis is non-negative.
|
||||
#[test]
|
||||
fn a_negative_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
for b in ["-0.01", "-1", "-500.00"] {
|
||||
let t = tb(r, Some(b));
|
||||
assert!(
|
||||
matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
),
|
||||
"{r:?} {b}"
|
||||
);
|
||||
assert!(l.check_asset_tax_basis(id, t).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 2: in cents at most.
|
||||
#[test]
|
||||
fn more_than_two_decimal_places_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
for b in ["500.001", "0.005", "1.2345"] {
|
||||
let t = tb(r, Some(b));
|
||||
assert!(
|
||||
matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
),
|
||||
"{r:?} {b}"
|
||||
);
|
||||
assert!(l.check_asset_tax_basis(id, t).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// Valid records: zero is non-negative; whole dollars and one decimal
|
||||
/// place are "in cents at most". check agrees with set, and a valid set
|
||||
/// changes nothing but the asset's tax record (book figures don't change).
|
||||
#[test]
|
||||
fn valid_records_are_accepted_and_book_figures_stay() {
|
||||
for (r, b) in [
|
||||
(TaxBasisRule::Cost, None),
|
||||
(TaxBasisRule::Carryover, Some("0")),
|
||||
(TaxBasisRule::Carryover, Some("0.00")),
|
||||
(TaxBasisRule::Carryover, Some("750")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("750.5")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("750.50")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("2500.00")),
|
||||
] {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let t = tb(r, b);
|
||||
l.check_asset_tax_basis(id, t).unwrap();
|
||||
let before = l.get_asset(id).unwrap().clone();
|
||||
l.set_asset_tax_basis(id, t).unwrap();
|
||||
let after = l.get_asset(id).unwrap();
|
||||
assert_eq!(after.tax, Some(t), "{r:?} {b:?}");
|
||||
// Book figures don't change.
|
||||
let mut same = after.clone();
|
||||
same.tax = before.tax;
|
||||
assert_eq!(same, before, "{r:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// SCENARIO 2: set again, the new record replaces the old.
|
||||
#[test]
|
||||
fn setting_again_replaces() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("1000.00")));
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some("400.00"));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, Some(tb(TaxBasisRule::Carryover, Some("400.00"))));
|
||||
assert_eq!(a.tax_basis(), Some(d("400.00")));
|
||||
set(
|
||||
&mut l,
|
||||
id,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("1200.00"),
|
||||
);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(
|
||||
a.tax,
|
||||
Some(tb(TaxBasisRule::PersonalUseConversion, Some("1200.00")))
|
||||
);
|
||||
assert_eq!(a.tax_basis(), Some(d("1000.00")));
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, Some(tb(TaxBasisRule::Cost, None)));
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a refusal after a valid record leaves that record.
|
||||
#[test]
|
||||
fn a_refusal_keeps_the_earlier_record() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some("300.00"));
|
||||
let before = l.clone();
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Cost, Some("1.00")))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, None))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("-1.00")))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("1.001")))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(l, before);
|
||||
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("300.00")));
|
||||
}
|
||||
|
||||
/// Setting one asset's record leaves the others alone.
|
||||
#[test]
|
||||
fn a_record_belongs_to_one_asset() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1000.00");
|
||||
let y = register(&mut l, &a, "Desk", "400.00");
|
||||
set(&mut l, x, TaxBasisRule::Carryover, Some("800.00"));
|
||||
assert_eq!(l.get_asset(y).unwrap().tax, None);
|
||||
assert_eq!(l.get_asset(y).unwrap().tax_basis(), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tax_basis
|
||||
|
||||
/// Not recorded: None (and no built-in gain).
|
||||
#[test]
|
||||
fn no_record_means_no_tax_basis() {
|
||||
let (l, id) = one("1000.00");
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, None);
|
||||
assert_eq!(a.tax_basis(), None);
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Cost: the book cost (IRC §1012(a)).
|
||||
#[test]
|
||||
fn cost_basis_is_the_book_cost() {
|
||||
let (mut l, id) = one("1234.56");
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d("1234.56")));
|
||||
// A purchase has no built-in gain.
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: Carryover: the contributor's adjusted basis (IRC §723),
|
||||
/// whether above or below fair market value.
|
||||
#[test]
|
||||
fn carryover_basis_is_the_contributors_basis() {
|
||||
for (fmv, basis, gain) in [
|
||||
("1000.00", "600.00", "400.00"), // built-in gain
|
||||
("1000.00", "1500.00", "-500.00"), // built-in loss
|
||||
("1000.00", "1000.00", "0.00"), // boundary
|
||||
("1000.00", "0.00", "1000.00"), // fully depreciated
|
||||
] {
|
||||
let (mut l, id) = one(fmv);
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some(basis));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d(basis)), "{fmv} {basis}");
|
||||
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 3: PersonalUseConversion: min(book cost, contributor's basis)
|
||||
/// (Treas. Reg. §1.168(i)-4(b)(1): "the lesser of its fair market value or
|
||||
/// its adjusted depreciable basis").
|
||||
#[test]
|
||||
fn personal_use_conversion_is_the_lesser_of_value_and_basis() {
|
||||
for (fmv, basis, tax, gain) in [
|
||||
// Paid 2,000, worth 1,200 now: basis 1,200; built-in loss −800.
|
||||
("1200.00", "2000.00", "1200.00", "-800.00"),
|
||||
// Paid 300, worth 450: basis 300; built-in gain 150.
|
||||
("450.00", "300.00", "300.00", "150.00"),
|
||||
// Boundary: value equals basis.
|
||||
("999.99", "999.99", "999.99", "0.00"),
|
||||
// A cent either side of the boundary.
|
||||
("1000.00", "1000.01", "1000.00", "-0.01"),
|
||||
("1000.00", "999.99", "999.99", "0.01"),
|
||||
] {
|
||||
let (mut l, id) = one(fmv);
|
||||
set(&mut l, id, TaxBasisRule::PersonalUseConversion, Some(basis));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d(tax)), "{fmv} {basis}");
|
||||
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// built_in_gain: FMV (book cost) less the contributor's basis; positive
|
||||
/// for a gain, negative for a loss, zero at FMV == basis, regardless of
|
||||
/// which contribution rule (the definition does not depend on the rule).
|
||||
#[test]
|
||||
fn built_in_gain_sign_and_boundary() {
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
let (mut l, id) = one("800.00");
|
||||
set(&mut l, id, r, Some("500.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert_eq!(g, d("300.00"));
|
||||
assert!(g > Decimal::ZERO, "{r:?}: FMV > basis is a gain");
|
||||
|
||||
set(&mut l, id, r, Some("1100.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert_eq!(g, d("-300.00"));
|
||||
assert!(g < Decimal::ZERO, "{r:?}: basis > FMV is a loss");
|
||||
|
||||
set(&mut l, id, r, Some("800.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert!(g.is_zero(), "{r:?}: FMV == basis is neither");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- register
|
||||
|
||||
struct RegisterBooks {
|
||||
l: Ledger,
|
||||
bought: AssetId,
|
||||
carried: AssetId,
|
||||
converted: AssetId,
|
||||
unrecorded: AssetId,
|
||||
sold: AssetId,
|
||||
}
|
||||
|
||||
/// Five assets: a purchase at cost, a carryover contribution, a
|
||||
/// personal-use conversion, one with no tax basis, and one disposed (with
|
||||
/// a tax basis, which the totals must leave out).
|
||||
fn register_books() -> RegisterBooks {
|
||||
let (mut l, a) = fixture();
|
||||
let bought = register(&mut l, &a, "Server", "3000.00");
|
||||
let carried = register(&mut l, &a, "Printer", "500.00");
|
||||
let converted = register(&mut l, &a, "Laptop", "1200.00");
|
||||
let unrecorded = register(&mut l, &a, "Desk", "700.00");
|
||||
let sold = register(&mut l, &a, "Old monitor", "360.00");
|
||||
set(&mut l, bought, TaxBasisRule::Cost, None);
|
||||
set(&mut l, carried, TaxBasisRule::Carryover, Some("650.25"));
|
||||
set(
|
||||
&mut l,
|
||||
converted,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("2000.00"),
|
||||
);
|
||||
set(&mut l, sold, TaxBasisRule::Cost, None);
|
||||
l.dispose_asset(sold, date(2026, 3, 31), usd("100.00"))
|
||||
.unwrap();
|
||||
RegisterBooks {
|
||||
l,
|
||||
bought,
|
||||
carried,
|
||||
converted,
|
||||
unrecorded,
|
||||
sold,
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 5: each row's tax fields are the asset's.
|
||||
#[test]
|
||||
fn register_rows_carry_the_assets_tax_fields() {
|
||||
let b = register_books();
|
||||
let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.rows.len(), 5);
|
||||
for row in &r.rows {
|
||||
let a = b.l.get_asset(row.asset_id).unwrap();
|
||||
assert_eq!(row.tax_rule, a.tax.map(|t| t.rule));
|
||||
assert_eq!(
|
||||
row.contributor_basis,
|
||||
a.tax.and_then(|t| t.contributor_basis)
|
||||
);
|
||||
assert_eq!(row.tax_basis, a.tax_basis());
|
||||
assert_eq!(row.built_in_gain, a.built_in_gain());
|
||||
}
|
||||
let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap();
|
||||
// By hand.
|
||||
let x = row(b.bought);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
|
||||
assert_eq!(x.contributor_basis, None);
|
||||
assert_eq!(x.tax_basis, Some(d("3000.00")));
|
||||
assert_eq!(x.built_in_gain, None);
|
||||
let x = row(b.carried);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Carryover));
|
||||
assert_eq!(x.contributor_basis, Some(d("650.25")));
|
||||
assert_eq!(x.tax_basis, Some(d("650.25")));
|
||||
assert_eq!(x.built_in_gain, Some(d("-150.25")));
|
||||
let x = row(b.converted);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::PersonalUseConversion));
|
||||
assert_eq!(x.contributor_basis, Some(d("2000.00")));
|
||||
assert_eq!(x.tax_basis, Some(d("1200.00")));
|
||||
assert_eq!(x.built_in_gain, Some(d("-800.00")));
|
||||
let x = row(b.unrecorded);
|
||||
assert_eq!(x.tax_rule, None);
|
||||
assert_eq!(x.contributor_basis, None);
|
||||
assert_eq!(x.tax_basis, None);
|
||||
assert_eq!(x.built_in_gain, None);
|
||||
// A disposed asset's row still shows its record.
|
||||
let x = row(b.sold);
|
||||
assert!(x.disposed.is_some());
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
|
||||
assert_eq!(x.tax_basis, Some(d("360.00")));
|
||||
}
|
||||
|
||||
/// INVARIANT 3: total_tax_basis sums held assets with a basis, exactly;
|
||||
/// missing_tax_basis counts held ones without; disposed excluded.
|
||||
#[test]
|
||||
fn register_tax_totals_cover_held_assets_only() {
|
||||
let b = register_books();
|
||||
let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap();
|
||||
// 3000.00 + 650.25 + 1200.00 (the disposed 360.00 left out).
|
||||
assert_eq!(r.total_tax_basis, d("4850.25"));
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
// Before the disposal date the old monitor is still held.
|
||||
let r = fixed_asset_register(&b.l, date(2026, 2, 28)).unwrap();
|
||||
assert_eq!(r.total_tax_basis, d("5210.25"));
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
}
|
||||
|
||||
/// A disposed asset without a tax basis isn't counted as missing.
|
||||
#[test]
|
||||
fn a_disposed_asset_without_a_basis_is_not_missing() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1000.00");
|
||||
let y = register(&mut l, &a, "Desk", "400.00");
|
||||
l.dispose_asset(y, date(2026, 3, 31), usd("0.00")).unwrap();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
assert_eq!(r.total_tax_basis, Decimal::ZERO);
|
||||
set(&mut l, x, TaxBasisRule::Carryover, Some("900.00"));
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
assert_eq!(r.total_tax_basis, d("900.00"));
|
||||
}
|
||||
|
||||
/// No assets: zero total, none missing.
|
||||
#[test]
|
||||
fn an_empty_register_has_no_tax_basis() {
|
||||
let (l, _) = fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert!(r.rows.is_empty());
|
||||
assert_eq!(r.total_tax_basis, Decimal::ZERO);
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
}
|
||||
|
||||
/// Recording tax basis leaves the book totals alone.
|
||||
#[test]
|
||||
fn tax_basis_does_not_move_book_totals() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1200.00");
|
||||
let before = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
set(
|
||||
&mut l,
|
||||
x,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("2000.00"),
|
||||
);
|
||||
let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(after.total_cost, before.total_cost);
|
||||
assert_eq!(after.total_accumulated, before.total_accumulated);
|
||||
assert_eq!(after.total_book_value, before.total_book_value);
|
||||
assert_eq!(after.ledger_cost, before.ledger_cost);
|
||||
assert_eq!(after.ledger_accumulated, before.ledger_accumulated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- proptest
|
||||
|
||||
fn cents() -> impl Strategy<Value = Decimal> {
|
||||
(0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
fn positive_cents() -> impl Strategy<Value = Decimal> {
|
||||
(1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// Asset::tax_basis INVARIANTS 1–3 and built_in_gain over random
|
||||
/// costs, bases, and rules; register totals agree.
|
||||
#[test]
|
||||
fn prop_tax_basis_follows_its_rule(
|
||||
assets in proptest::collection::vec((positive_cents(), cents(), 0usize..4), 1..8)
|
||||
) {
|
||||
let (mut l, a) = fixture();
|
||||
let mut expected_total = Decimal::ZERO;
|
||||
let mut expected_missing = 0usize;
|
||||
for (i, (cost, basis, rule)) in assets.iter().enumerate() {
|
||||
let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string());
|
||||
let want = match rule {
|
||||
0 => {
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
Some((*cost, None))
|
||||
}
|
||||
1 => {
|
||||
l.set_asset_tax_basis(id, TaxBasis {
|
||||
rule: TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(*basis),
|
||||
}).unwrap();
|
||||
Some((*basis, Some(*cost - *basis)))
|
||||
}
|
||||
2 => {
|
||||
l.set_asset_tax_basis(id, TaxBasis {
|
||||
rule: TaxBasisRule::PersonalUseConversion,
|
||||
contributor_basis: Some(*basis),
|
||||
}).unwrap();
|
||||
Some((if cost < basis { *cost } else { *basis }, Some(*cost - *basis)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let asset = l.get_asset(id).unwrap();
|
||||
match want {
|
||||
Some((tax, gain)) => {
|
||||
prop_assert_eq!(asset.tax_basis(), Some(tax));
|
||||
prop_assert_eq!(asset.built_in_gain(), gain);
|
||||
expected_total += tax;
|
||||
}
|
||||
None => {
|
||||
prop_assert_eq!(asset.tax_basis(), None);
|
||||
expected_missing += 1;
|
||||
}
|
||||
}
|
||||
if let Some(t) = asset.tax_basis() {
|
||||
// Personal-use conversion never exceeds fair market value.
|
||||
if *rule == 2 {
|
||||
prop_assert!(t <= *cost);
|
||||
prop_assert!(t <= *basis);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
prop_assert_eq!(r.total_tax_basis, expected_total);
|
||||
prop_assert_eq!(r.missing_tax_basis, expected_missing);
|
||||
}
|
||||
|
||||
/// check agrees with set, and a refused record changes nothing.
|
||||
#[test]
|
||||
fn prop_check_agrees_with_set(
|
||||
cents in -1_000_000i64..1_000_000,
|
||||
scale in 0u32..5,
|
||||
rule in 0usize..3,
|
||||
some in any::<bool>(),
|
||||
) {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let basis = some.then(|| Decimal::new(cents, scale));
|
||||
let t = TaxBasis { rule: RULES[rule], contributor_basis: basis };
|
||||
let before = l.clone();
|
||||
let checked = l.check_asset_tax_basis(id, t);
|
||||
let done = l.set_asset_tax_basis(id, t);
|
||||
prop_assert_eq!(checked.is_ok(), done.is_ok());
|
||||
// The spec, independently: INVARIANTS 1 and 2.
|
||||
let valid = match (RULES[rule], basis) {
|
||||
(TaxBasisRule::Cost, None) => true,
|
||||
(TaxBasisRule::Cost, Some(_)) => false,
|
||||
(_, None) => false,
|
||||
(_, Some(b)) => b >= Decimal::ZERO && b.normalize().scale() <= 2,
|
||||
};
|
||||
prop_assert_eq!(done.is_ok(), valid, "{:?} {:?}", RULES[rule], basis);
|
||||
if done.is_err() {
|
||||
prop_assert_eq!(&l, &before);
|
||||
} else {
|
||||
prop_assert_eq!(l.get_asset(id).unwrap().tax, Some(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1210,7 +1210,7 @@ fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
|
||||
.unwrap();
|
||||
l.register_asset(ledger_core::NewAsset {
|
||||
description: "Laptop".to_string(),
|
||||
purchase_date: d(1, 10),
|
||||
in_service_date: d(1, 10),
|
||||
cost: usd(1_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 10,
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Spec tests for the K-1 worksheet's tax-basis capital
|
||||
//! (`ledger_core::tax_capital`, rules.md B-36): Item L on the tax basis
|
||||
//! beside the book figures, every figure the books can't know left open
|
||||
//! and named, and the reconciliation from book to tax capital.
|
||||
//!
|
||||
//! Expected values are worked by hand from the Form 1065 instructions'
|
||||
//! Item L (`docs/spec/sources/irs-partnerships.md`): capital contributed is
|
||||
//! "the amount of cash plus the adjusted tax basis of all property
|
||||
//! contributed"; withdrawals and distributions "the amount of cash plus
|
||||
//! the adjusted tax basis of all property distributed". Names and figures
|
||||
//! are invented.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::MemberCapital;
|
||||
use ledger_core::tax_capital::{Open, Reconciliation, tax_basis_capital};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money,
|
||||
NewAsset, Sec704cMethod, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(d(s), Currency::USD)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
cash: AccountId,
|
||||
prepaid: AccountId,
|
||||
equipment: AccountId,
|
||||
accum: AccountId,
|
||||
tobin: AccountId,
|
||||
morgan: AccountId,
|
||||
revenue: AccountId,
|
||||
depreciation: AccountId,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = add("1000", "Bank", AccountType::Asset);
|
||||
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
|
||||
let equipment = add("1500", "Equipment", AccountType::Asset);
|
||||
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
|
||||
let tobin = add("3000", "Capital, Tobin Harlow", AccountType::Equity);
|
||||
let morgan = add("3001", "Capital, Morgan Ellis", AccountType::Equity);
|
||||
let revenue = add("4000", "Revenue", AccountType::Revenue);
|
||||
let depreciation = add("6200", "Depreciation", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
cash,
|
||||
prepaid,
|
||||
equipment,
|
||||
accum,
|
||||
tobin,
|
||||
morgan,
|
||||
revenue,
|
||||
depreciation,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.try_create_entry(on, "entry").unwrap();
|
||||
for (a, side, amt) in lines {
|
||||
l.add_line(e, *a, *side, usd(amt)).unwrap();
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn members(b: &Books) -> [MemberCapital; 2] {
|
||||
[
|
||||
MemberCapital {
|
||||
name: "Tobin Harlow".into(),
|
||||
capital_account: b.tobin,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
MemberCapital {
|
||||
name: "Morgan Ellis".into(),
|
||||
capital_account: b.morgan,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// A device two members contribute, in service 2026-01-01 over 24 months.
|
||||
fn device(b: &mut Books, name: &str, fmv: &str, adjusted: &str, entry: EntryId) -> AssetId {
|
||||
let id =
|
||||
b.l.register_asset(NewAsset {
|
||||
description: name.into(),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd(fmv),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 24,
|
||||
cost_account: b.equipment,
|
||||
accum_depr_account: b.accum,
|
||||
expense_account: b.depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
b.l.set_asset_acquisition(id, entry).unwrap();
|
||||
let mut basis = AssetBasis::contribution(BasisRule::PersonalUseConversion, d(adjusted), d(fmv));
|
||||
basis.sec704c_method = Some(Sec704cMethod::Traditional);
|
||||
b.l.set_asset_basis(id, basis).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Every month's run Jan..=Dec 2026, as the schedule gives it.
|
||||
fn a_year_of_runs(b: &mut Books, id: AssetId) {
|
||||
let a = b.l.get_asset(id).unwrap().clone();
|
||||
for l in ledger_core::depreciation_schedule(&a, date(2026, 12, 31)).unwrap() {
|
||||
let (y, m) = (
|
||||
l.period[..4].parse().unwrap(),
|
||||
l.period[5..].parse().unwrap(),
|
||||
);
|
||||
let end = date(y, m, 1)
|
||||
.checked_add_months(chrono::Months::new(1))
|
||||
.unwrap()
|
||||
.pred_opt()
|
||||
.unwrap();
|
||||
let amt = l.amount.to_string();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
end,
|
||||
&[
|
||||
(b.depreciation, Side::Debit, &amt),
|
||||
(b.accum, Side::Credit, &amt),
|
||||
],
|
||||
);
|
||||
b.l.check_depreciation_run(id, &l.period, e, l.amount)
|
||||
.unwrap();
|
||||
b.l.record_depreciation_run(id, &l.period, e, l.amount)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The year, by hand. Tobin Harlow and Morgan Ellis, 50/50:
|
||||
/// - 2026-01-01 cash in: Dr 1000 2,000.00 / Cr 3000 1,000.00, Cr 3001
|
||||
/// 1,000.00;
|
||||
/// - 2026-01-01 two devices in, jointly, from personal use: a laptop worth
|
||||
/// 1,440.00 (adjusted basis 1,688.40: built-in loss 248.40) and a camera
|
||||
/// worth 1,300.00 (basis 1,080.00: built-in gain 220.00); Dr 1500
|
||||
/// 2,740.00 / Cr 3000 1,370.00, Cr 3001 1,370.00;
|
||||
/// - revenue 600.00; a year of depreciation, 24-month lives: 1,440/2 +
|
||||
/// 1,300/2 = 720.00 + 650.00 = 1,370.00; net income 600.00 − 1,370.00 =
|
||||
/// −770.00, −385.00 each;
|
||||
/// - 2026-12-15 cash out: Dr 3000 300.00, Dr 3001 300.00 / Cr 1000 600.00.
|
||||
///
|
||||
/// Book: 1,000.00 + 1,370.00 − 385.00 − 300.00 = 1,685.00 each.
|
||||
/// Tax: property at §723 basis, (1,688.40 + 1,080.00) / 2 = 1,384.20 each
|
||||
/// (book 1,370.00 less the built-in gain share (−248.40 + 220.00) / 2 =
|
||||
/// −14.20); the year's income is open (tax depreciation is the CPA's), so
|
||||
/// the ending the books know is 1,000.00 + 1,384.20 − 385.00 − 300.00 =
|
||||
/// 1,699.20; book less tax = −14.20, the built-in gain share.
|
||||
fn the_year() -> (Books, EntryId, AssetId, AssetId) {
|
||||
let mut b = books();
|
||||
let (cash, tobin, morgan, equipment) = (b.cash, b.tobin, b.morgan, b.equipment);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "2000.00"),
|
||||
(tobin, Side::Credit, "1000.00"),
|
||||
(morgan, Side::Credit, "1000.00"),
|
||||
],
|
||||
);
|
||||
let devices = post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 1),
|
||||
&[
|
||||
(equipment, Side::Debit, "1440.00"),
|
||||
(equipment, Side::Debit, "1300.00"),
|
||||
(tobin, Side::Credit, "1370.00"),
|
||||
(morgan, Side::Credit, "1370.00"),
|
||||
],
|
||||
);
|
||||
let laptop = device(&mut b, "Laptop", "1440.00", "1688.40", devices);
|
||||
let camera = device(&mut b, "Camera", "1300.00", "1080.00", devices);
|
||||
let revenue = b.revenue;
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 2, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "600.00"),
|
||||
(revenue, Side::Credit, "600.00"),
|
||||
],
|
||||
);
|
||||
a_year_of_runs(&mut b, laptop);
|
||||
a_year_of_runs(&mut b, camera);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 12, 15),
|
||||
&[
|
||||
(tobin, Side::Debit, "300.00"),
|
||||
(morgan, Side::Debit, "300.00"),
|
||||
(cash, Side::Credit, "600.00"),
|
||||
],
|
||||
);
|
||||
(b, devices, laptop, camera)
|
||||
}
|
||||
|
||||
/// Item L on the tax basis, per member, for the year (B-36): a cash
|
||||
/// contribution, a contribution of property, a distribution, a year of
|
||||
/// depreciation.
|
||||
#[test]
|
||||
fn tax_basis_capital_per_member() {
|
||||
let (b, _, _, _) = the_year();
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[b.cash],
|
||||
)
|
||||
.unwrap();
|
||||
let depreciation = Open::TaxDepreciation {
|
||||
book: d("1370.00"),
|
||||
contributed_book: d("1370.00"),
|
||||
};
|
||||
assert_eq!(tc.open, vec![depreciation.clone()]);
|
||||
for row in &tc.rows {
|
||||
// The book column is the K-1 worksheet's row.
|
||||
assert_eq!(row.book.beginning, d("0"));
|
||||
assert_eq!(row.book.contributions_cash, d("1000.00"));
|
||||
assert_eq!(row.book.contributions_property, d("1370.00"));
|
||||
assert_eq!(row.book.net_income, d("-385.00"));
|
||||
assert_eq!(row.book.distributions, d("300.00"));
|
||||
assert_eq!(row.book.ending, d("1685.00"));
|
||||
// The tax column.
|
||||
assert_eq!(row.beginning.tax(), Some(d("0")));
|
||||
assert_eq!(row.contributions_cash, d("1000.00"));
|
||||
assert_eq!(row.contributions_property.tax(), Some(d("1384.20")));
|
||||
assert_eq!(row.net_income.tax(), None, "tax depreciation is the CPA's");
|
||||
assert_eq!(row.net_income.known, d("-385.00"));
|
||||
assert_eq!(row.net_income.open, vec![depreciation.clone()]);
|
||||
assert_eq!(row.other.tax(), Some(d("0")));
|
||||
assert_eq!(row.distributions.tax(), Some(d("300.00")));
|
||||
assert_eq!(row.ending.tax(), None);
|
||||
assert_eq!(row.ending.known, d("1699.20"));
|
||||
assert_eq!(row.ending.open, vec![depreciation.clone()]);
|
||||
// Book less tax, by cause.
|
||||
assert_eq!(
|
||||
row.reconciliation,
|
||||
Reconciliation {
|
||||
beginning: d("0"),
|
||||
built_in_gain_704c: d("-14.20"),
|
||||
property_without_basis: d("0"),
|
||||
property_distributed: d("0"),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beginning capital the next year is this year's ending as the books
|
||||
/// know it, left open while an earlier year left a figure open.
|
||||
#[test]
|
||||
fn next_years_beginning_carries_what_is_known_and_says_what_isnt() {
|
||||
let (b, _, _, _) = the_year();
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2027, 1, 1),
|
||||
date(2027, 12, 31),
|
||||
&members(&b),
|
||||
&[b.cash],
|
||||
)
|
||||
.unwrap();
|
||||
for row in &tc.rows {
|
||||
assert_eq!(row.book.beginning, d("1685.00"));
|
||||
assert_eq!(row.beginning.known, d("1699.20"));
|
||||
assert_eq!(row.beginning.open, vec![Open::PriorYears]);
|
||||
assert_eq!(row.reconciliation.beginning, d("-14.20"));
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cash only, no fixed assets: every line is the tax figure, equal to the
|
||||
/// book figure.
|
||||
#[test]
|
||||
fn cash_only_books_are_complete() {
|
||||
let mut b = books();
|
||||
let (cash, tobin, morgan, revenue) = (b.cash, b.tobin, b.morgan, b.revenue);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 3, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "500.00"),
|
||||
(tobin, Side::Credit, "250.00"),
|
||||
(morgan, Side::Credit, "250.00"),
|
||||
],
|
||||
);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 4, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "100.01"),
|
||||
(revenue, Side::Credit, "100.01"),
|
||||
],
|
||||
);
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[cash],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tc.open.is_empty());
|
||||
for row in &tc.rows {
|
||||
assert_eq!(row.ending.tax(), Some(row.book.ending));
|
||||
assert!(row.ending.open.is_empty());
|
||||
assert_eq!(row.reconciliation, Reconciliation::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Property contributed with no basis in the books (a prepaid contract
|
||||
/// handed over, no registered asset) is open with its book amount; a
|
||||
/// linked asset carrying a purchase's basis on a contribution is open
|
||||
/// too. Neither is guessed.
|
||||
#[test]
|
||||
fn property_without_a_basis_is_open_never_guessed() {
|
||||
let mut b = books();
|
||||
let (prepaid, equipment, tobin, morgan, cash) =
|
||||
(b.prepaid, b.equipment, b.tobin, b.morgan, b.cash);
|
||||
let contract = post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 5),
|
||||
&[
|
||||
(prepaid, Side::Debit, "25.00"),
|
||||
(tobin, Side::Credit, "12.50"),
|
||||
(morgan, Side::Credit, "12.50"),
|
||||
],
|
||||
);
|
||||
let tool = post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 6),
|
||||
&[
|
||||
(equipment, Side::Debit, "400.00"),
|
||||
(tobin, Side::Credit, "400.00"),
|
||||
],
|
||||
);
|
||||
let id =
|
||||
b.l.register_asset(NewAsset {
|
||||
description: "Drill".into(),
|
||||
in_service_date: date(2026, 1, 6),
|
||||
cost: usd("400.00"),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 24,
|
||||
cost_account: b.equipment,
|
||||
accum_depr_account: b.accum,
|
||||
expense_account: b.depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
b.l.set_asset_acquisition(id, tool).unwrap();
|
||||
b.l.set_asset_basis(id, AssetBasis::purchase()).unwrap();
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[cash],
|
||||
)
|
||||
.unwrap();
|
||||
let (t, m) = (&tc.rows[0], &tc.rows[1]);
|
||||
assert_eq!(t.book.contributions_property, d("412.50"));
|
||||
assert_eq!(t.contributions_property.known, d("0"));
|
||||
assert_eq!(
|
||||
t.contributions_property.open,
|
||||
vec![
|
||||
Open::PropertyBasis {
|
||||
entry: contract,
|
||||
book: d("12.50")
|
||||
},
|
||||
Open::PropertyBasis {
|
||||
entry: tool,
|
||||
book: d("400.00")
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(t.reconciliation.property_without_basis, d("412.50"));
|
||||
assert_eq!(m.contributions_property.known, d("0"));
|
||||
assert_eq!(m.reconciliation.property_without_basis, d("12.50"));
|
||||
for row in &tc.rows {
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Property distributed: its adjusted tax basis is the CPA's (it needs
|
||||
/// tax depreciation), so the line is open with its book amount.
|
||||
#[test]
|
||||
fn property_distributed_is_open_at_its_book_amount() {
|
||||
let mut b = books();
|
||||
let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "1000.00"),
|
||||
(tobin, Side::Credit, "500.00"),
|
||||
(morgan, Side::Credit, "500.00"),
|
||||
],
|
||||
);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 2),
|
||||
&[
|
||||
(equipment, Side::Debit, "100.00"),
|
||||
(cash, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
let out = post(
|
||||
&mut b.l,
|
||||
date(2026, 6, 1),
|
||||
&[
|
||||
(tobin, Side::Debit, "100.00"),
|
||||
(equipment, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[cash],
|
||||
)
|
||||
.unwrap();
|
||||
let t = &tc.rows[0];
|
||||
assert_eq!(t.book.distributions, d("100.00"));
|
||||
assert_eq!(t.distributions.known, d("0"));
|
||||
assert_eq!(
|
||||
t.distributions.open,
|
||||
vec![Open::PropertyDistribution {
|
||||
entry: out,
|
||||
book: d("100.00")
|
||||
}]
|
||||
);
|
||||
assert_eq!(t.reconciliation.property_distributed, d("100.00"));
|
||||
assert_eq!(
|
||||
t.reconciliation.total(),
|
||||
Some(t.book.ending - t.ending.known)
|
||||
);
|
||||
assert_eq!(tc.rows[1].distributions.tax(), Some(d("0")));
|
||||
}
|
||||
|
||||
/// tax_capital INVARIANTS 4 and 5 hold for every row of the year and of
|
||||
/// every span within it.
|
||||
#[test]
|
||||
fn every_span_rolls_forward_and_reconciles() {
|
||||
let (b, _, _, _) = the_year();
|
||||
for (start, end) in [
|
||||
(date(2026, 1, 1), date(2026, 6, 30)),
|
||||
(date(2026, 7, 1), date(2026, 12, 31)),
|
||||
(date(2026, 12, 15), date(2026, 12, 15)),
|
||||
] {
|
||||
let tc = tax_basis_capital(&b.l, start, end, &members(&b), &[b.cash]).unwrap();
|
||||
for row in &tc.rows {
|
||||
assert_eq!(
|
||||
row.ending.known,
|
||||
row.beginning.known
|
||||
+ row.contributions_cash
|
||||
+ row.contributions_property.known
|
||||
+ row.net_income.known
|
||||
+ row.other.known
|
||||
- row.distributions.known,
|
||||
"{start}..{end}"
|
||||
);
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known),
|
||||
"{start}..{end}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user