Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Waiting to run

- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md)
- store: asset.tax_basis_set journaled, replayed, projected (schema v10)
- mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset
- import: the register's XLSX and PDF gain the federal tax basis table with citations
- viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one
- just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp)
- docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md)
- tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 13:30:09 -06:00
1 parent 49c49aef33
commit 6e6f9d65b2
30 files changed
+3240 -93

No files matched your search

+112
View File
@@ -80,6 +80,118 @@ pub struct Asset {
/// ([`crate::Ledger::set_asset_acquisition`]). /// ([`crate::Ledger::set_asset_acquisition`]).
#[serde(default)] #[serde(default)]
pub acquired_by: Option<crate::EntryId>, pub acquired_by: Option<crate::EntryId>,
/// Its federal tax basis, if recorded ([`crate::Ledger::set_asset_tax_basis`]).
/// Book cost is [`Asset::cost`]; tax basis can differ (a member's
/// contribution carries the member's basis, not its market value).
#[serde(default)]
pub tax: Option<TaxBasis>,
}
/// Which rule sets an asset's federal tax basis.
///
/// ```text
/// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost),
/// IRC §723 (property a partner contributes keeps the partner's
/// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted
/// from personal use depreciates from the lesser of its fair market
/// value and its adjusted basis), IRC §704(c)(1)(C) (built-in loss).
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxBasisRule {
/// Bought by the company: its cost (IRC §1012(a)), the book cost.
Cost,
/// Contributed by a member from business use: the member's adjusted
/// basis carries over (IRC §723).
Carryover,
/// Contributed by a member from personal use: depreciable basis is the
/// lesser of fair market value (the book cost) and the member's
/// adjusted basis (Treas. Reg. §1.168(i)-4(b), with IRC §723).
PersonalUseConversion,
}
impl TaxBasisRule {
/// Its wire name: `cost`, `carryover`, `personal_use_conversion`.
pub fn key(self) -> &'static str {
match self {
TaxBasisRule::Cost => "cost",
TaxBasisRule::Carryover => "carryover",
TaxBasisRule::PersonalUseConversion => "personal_use_conversion",
}
}
/// The rule named `key`.
pub fn parse(key: &str) -> Option<Self> {
[Self::Cost, Self::Carryover, Self::PersonalUseConversion]
.into_iter()
.find(|r| r.key() == key)
}
}
/// An asset's federal tax basis: the rule and, for a member's
/// contribution, the member's adjusted basis at the time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaxBasis {
/// The rule.
pub rule: TaxBasisRule,
/// The contributing member's adjusted basis at contribution (cost less
/// any depreciation they took). `None` for [`TaxBasisRule::Cost`].
pub contributor_basis: Option<Decimal>,
}
impl TaxBasis {
/// Whether the record fits its rule: Cost takes no contributor basis;
/// Carryover and PersonalUseConversion require a non-negative one in
/// cents at most. Returns why not. Needs no asset, so a caller can check
/// before registering one.
pub fn check(&self) -> Result<(), String> {
use TaxBasisRule::*;
match (self.rule, self.contributor_basis) {
(Cost, Some(_)) => {
Err("a purchase's tax basis is its cost; give no contributor basis".into())
}
(Carryover | PersonalUseConversion, None) => {
Err("a contribution needs the contributing member's adjusted basis".into())
}
(_, Some(b)) if b.is_sign_negative() && !b.is_zero() => {
Err("a basis can't be negative".into())
}
(_, Some(b)) if b.normalize().scale() > 2 => Err("a basis is in cents at most".into()),
_ => Ok(()),
}
}
}
impl Asset {
/// Its depreciable federal tax basis, by its rule, or `None` if not
/// recorded.
///
/// ```text
/// INVARIANTS:
/// 1. Cost: the book cost.
/// 2. Carryover: the contributor's adjusted basis.
/// 3. PersonalUseConversion: min(book cost, contributor's basis); the
/// book cost of a contribution is its fair market value.
/// ```
pub fn tax_basis(&self) -> Option<Decimal> {
let t = self.tax?;
Some(match (t.rule, t.contributor_basis) {
(TaxBasisRule::Cost, _) => self.cost.amount,
(TaxBasisRule::Carryover, Some(b)) => b,
(TaxBasisRule::PersonalUseConversion, Some(b)) => b.min(self.cost.amount),
(_, None) => return None,
})
}
/// Built-in gain (positive) or loss (negative) at contribution, for
/// IRC §704(c): fair market value (the book cost) less the contributor's
/// adjusted basis. `None` for a purchase or when not recorded. A
/// built-in loss counts only for the contributing member
/// (§704(c)(1)(C)).
pub fn built_in_gain(&self) -> Option<Decimal> {
let b = self.tax?.contributor_basis?;
Some(self.cost.amount - b)
}
} }
/// The longest asset tag. /// The longest asset tag.
+5
View File
@@ -147,6 +147,11 @@ pub enum LedgerError {
why: String, why: String,
}, },
/// An asset's tax basis record doesn't fit its rule (see
/// `Ledger::set_asset_tax_basis`).
#[error("bad tax basis: {0}")]
InvalidTaxBasis(String),
/// A serial number of the wrong form (see `assets::check_serial`). /// A serial number of the wrong form (see `assets::check_serial`).
#[error("bad serial number: {0}")] #[error("bad serial number: {0}")]
InvalidSerial(String), InvalidSerial(String),
+42
View File
@@ -1459,6 +1459,7 @@ impl Ledger {
tag: None, tag: None,
serial: None, serial: None,
acquired_by: None, acquired_by: None,
tax: None,
}, },
); );
Ok(id) Ok(id)
@@ -1656,6 +1657,47 @@ impl Ledger {
Ok(()) Ok(())
} }
/// Record an asset's federal tax basis: the rule, and for a member's
/// contribution the member's adjusted basis. Book figures don't change.
///
/// ```text
/// GOAL: the register carries tax basis beside book value, so book and
/// tax never get confused and the CPA sees both.
/// SOURCE: docs/spec/rules.md B-25; docs/spec/sources/
/// irs-partnership-basis.md.
/// INVARIANTS:
/// 1. Cost takes no contributor basis; Carryover and
/// PersonalUseConversion require one.
/// 2. A contributor basis is non-negative, in cents at most.
/// 3. A refusal MUST leave the books unchanged.
/// SCENARIOS:
/// 1. IF the asset is unknown, or 1 or 2 fails: SHOULD refuse with
/// UnknownAsset or InvalidTaxBasis.
/// 2. IF set again: the new record replaces the old (the journal
/// keeps both).
/// ```
pub fn set_asset_tax_basis(
&mut self,
id: AssetId,
tax: assets::TaxBasis,
) -> Result<(), LedgerError> {
self.check_asset_tax_basis(id, tax)?;
self.assets.get_mut(&id).expect("checked").tax = Some(tax);
Ok(())
}
/// The validation behind [`Self::set_asset_tax_basis`].
pub fn check_asset_tax_basis(
&self,
id: AssetId,
tax: assets::TaxBasis,
) -> Result<(), LedgerError> {
if !self.assets.contains_key(&id) {
return Err(LedgerError::UnknownAsset(id));
}
tax.check().map_err(LedgerError::InvalidTaxBasis)
}
/// The asset whose current tag is `tag`, ignoring case. /// The asset whose current tag is `tag`, ignoring case.
pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> { pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> {
let id = self.asset_tags.get(&assets::tag_key(tag))?; let id = self.asset_tags.get(&assets::tag_key(tag))?;
+2 -1
View File
@@ -44,7 +44,8 @@ pub use arap::{
}; };
pub use assets::{ pub use assets::{
Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine, Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine,
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value, depreciation_schedule,
disposal_amounts,
}; };
pub use classes::{Class, ClassId}; pub use classes::{Class, ClassId};
pub use error::LedgerError; pub use error::LedgerError;
+30 -1
View File
@@ -1556,6 +1556,15 @@ pub struct AssetRegisterRow {
pub book_value: Decimal, pub book_value: Decimal,
/// Disposal date and proceeds, if disposed by the date. /// Disposal date and proceeds, if disposed by the date.
pub disposed: Option<(NaiveDate, Decimal)>, pub disposed: Option<(NaiveDate, Decimal)>,
/// The federal tax basis rule, if recorded.
pub tax_rule: Option<crate::TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable federal tax basis (`Asset::tax_basis`), if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, IRC §704(c)
/// (`Asset::built_in_gain`).
pub built_in_gain: Option<Decimal>,
} }
/// The fixed asset register as of a date, reconciled to the general ledger. /// The fixed asset register as of a date, reconciled to the general ledger.
@@ -1572,6 +1581,11 @@ pub struct AssetRegister {
pub total_accumulated: Decimal, pub total_accumulated: Decimal,
/// Their book value. /// Their book value.
pub total_book_value: Decimal, pub total_book_value: Decimal,
/// The total depreciable tax basis of the assets still held that have
/// one recorded.
pub total_tax_basis: Decimal,
/// How many assets still held have no tax basis recorded.
pub missing_tax_basis: usize,
/// The general ledger's balance (debit positive) of the assets' cost /// The general ledger's balance (debit positive) of the assets' cost
/// accounts on the date. /// accounts on the date.
pub ledger_cost: Decimal, pub ledger_cost: Decimal,
@@ -1611,7 +1625,11 @@ impl AssetRegister {
/// 2. accumulated is the asset's schedule through as_of /// 2. accumulated is the asset's schedule through as_of
/// (assets::accumulated_depreciation); book_value = cost − /// (assets::accumulated_depreciation); book_value = cost −
/// accumulated. /// accumulated.
/// 3. Totals are the sums over the held assets, exactly. /// 3. Totals are the sums over the held assets, exactly; tax basis
/// totals over those with one recorded, and missing_tax_basis counts
/// the rest.
/// 5. Each row's tax fields are the asset's (Asset::tax_basis,
/// Asset::built_in_gain), never re-derived here.
/// 4. ledger_cost and ledger_accumulated are the general ledger's /// 4. ledger_cost and ledger_accumulated are the general ledger's
/// balances on as_of of the distinct cost and accumulated /// balances on as_of of the distinct cost and accumulated
/// depreciation accounts of the registered assets, so /// depreciation accounts of the registered assets, so
@@ -1622,6 +1640,7 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
use crate::AssetStatus; use crate::AssetStatus;
let mut rows = Vec::new(); let mut rows = Vec::new();
let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO); let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO);
let (mut tax_total, mut tax_missing) = (Decimal::ZERO, 0usize);
let mut cost_accounts = std::collections::BTreeSet::new(); let mut cost_accounts = std::collections::BTreeSet::new();
let mut accum_accounts = std::collections::BTreeSet::new(); let mut accum_accounts = std::collections::BTreeSet::new();
for a in ledger for a in ledger
@@ -1644,6 +1663,10 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
if disposed.is_none() { if disposed.is_none() {
cost += a.cost.amount; cost += a.cost.amount;
accumulated += acc; accumulated += acc;
match a.tax_basis() {
Some(t) => tax_total += t,
None => tax_missing += 1,
}
} }
rows.push(AssetRegisterRow { rows.push(AssetRegisterRow {
asset_id: a.id, asset_id: a.id,
@@ -1661,6 +1684,10 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
a.cost.amount - acc a.cost.amount - acc
}, },
disposed, disposed,
tax_rule: a.tax.map(|t| t.rule),
contributor_basis: a.tax.and_then(|t| t.contributor_basis),
tax_basis: a.tax_basis(),
built_in_gain: a.built_in_gain(),
}); });
} }
let tb = trial_balance_as_of(ledger, as_of); let tb = trial_balance_as_of(ledger, as_of);
@@ -1681,6 +1708,8 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
total_cost: cost, total_cost: cost,
total_accumulated: accumulated, total_accumulated: accumulated,
total_book_value: cost - accumulated, total_book_value: cost - accumulated,
total_tax_basis: tax_total,
missing_tax_basis: tax_missing,
ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(), ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(),
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(), ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
} }
@@ -61,6 +61,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
tag: None, tag: None,
serial: None, serial: None,
acquired_by: None, acquired_by: None,
tax: None,
} }
} }
+693
View File
@@ -0,0 +1,693 @@
//! 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));
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));
// 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));
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));
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));
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));
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));
set(
&mut l,
x,
TaxBasisRule::PersonalUseConversion,
Some("2000.00"),
);
let after = fixed_asset_register(&l, date(2026, 6, 30));
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));
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));
}
}
}
+28 -3
View File
@@ -1,8 +1,8 @@
//! What the books need: drafts left unposted, missing receipts, schedules //! What the books need: drafts left unposted, missing receipts, schedules
//! and recurring entries due, open past periods, uncategorized tax lines, //! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to //! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! owners, fixed asset depreciation not posted and assets without a tag, //! owners, fixed asset depreciation not posted, assets without a tag or a
//! and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that //! tax basis, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself). //! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity}; use crate::books::{Attention, Books, Severity};
@@ -36,7 +36,9 @@ use rust_decimal::Decimal;
/// and not posted (Books::depreciation_due amount > 0: Todo, one /// and not posted (Books::depreciation_due amount > 0: Todo, one
/// item); the asset register and the books' asset cost accounts /// item); the asset register and the books' asset cost accounts
/// disagreeing today (unregistered_cost != 0: Problem); an active /// disagreeing today (unregistered_cost != 0: Problem); an active
/// asset in service with no tag (Todo, one per asset). /// asset in service with no tag (Todo, one per asset); an active
/// asset in service with no tax basis (Asset::tax_basis is None:
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis).
/// 3. Nothing else: books with none of those yield no items. /// 3. Nothing else: books with none of those yield no items.
/// 4. Every item's prompt starts "In wanderbooks," or names /// 4. Every item's prompt starts "In wanderbooks," or names
/// wanderbooks, and its amounts are the books' exact amounts. /// wanderbooks, and its amounts are the books' exact amounts.
@@ -384,6 +386,29 @@ pub fn find(b: &Books) -> Vec<Attention> {
}); });
} }
for a in b.ledger.registered_assets().filter(|a| {
a.tax_basis().is_none()
&& a.status == ledger_core::AssetStatus::Active
&& a.purchase_date <= today
}) {
let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id));
out.push(Attention {
severity: Severity::Todo,
title: format!("No tax basis: {}", b.asset_label(a.id)),
detail: format!(
"Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.",
fmt::dollars(a.cost.amount),
fmt::date(a.purchase_date)
),
target: Some(Route::Asset(a.id)),
prompt: format!(
"In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (cost), contributed by a member from business use (carryover, IRC §723: the member's adjusted basis), or converted by a member from personal use (personal-use conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis; the tax basis is the lesser of that and its fair market value). Then show me the asset.",
a.description,
fmt::dollars(a.cost.amount)
),
});
}
use ledger_store::DiskCheck; use ledger_store::DiskCheck;
let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string(); let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string();
match &b.integrity.disk { match &b.integrity.disk {
+8
View File
@@ -379,6 +379,14 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
// The label on the laptop, and its serial number. // The label on the laptop, and its serial number.
s.set_asset_tag(asset, "JS-0001")?; s.set_asset_tag(asset, "JS-0001")?;
s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?; s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?;
// Bought by the company, so its federal tax basis is its cost.
s.set_asset_tax_basis(
asset,
ledger_core::TaxBasis {
rule: ledger_core::TaxBasisRule::Cost,
contributor_basis: None,
},
)?;
// Prepaid insurance, spread over a year. // Prepaid insurance, spread over a year.
let ins_date = day(start + Months::new(2), 15); let ins_date = day(start + Months::new(2), 15);
+315 -15
View File
@@ -2,7 +2,11 @@
//! asset, reconciled to the books) and one asset completely (its label, //! asset, reconciled to the books) and one asset completely (its label,
//! its schedule month by month, the entries and documents behind it). //! its schedule month by month, the entries and documents behind it).
//! Every number comes from `ledger-core` (`fixed_asset_register`, //! Every number comes from `ledger-core` (`fixed_asset_register`,
//! `depreciation_schedule`); what is posted comes from the journal. //! `depreciation_schedule`, `Asset::tax_basis`, `Asset::built_in_gain`);
//! what is posted comes from the journal. Book value and federal tax
//! basis sit side by side and are never confused: book cost is what was
//! paid or, for a member's contribution, its fair market value; tax basis
//! follows its rule (docs/spec/rules.md B-25).
use super::page; use super::page;
use crate::app::Shell; use crate::app::Shell;
@@ -14,7 +18,7 @@ use crate::widgets;
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2}; use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon; use egui_phosphor::regular as icon;
use ledger_core::{AssetId, AssetRegister, AssetStatus}; use ledger_core::{Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, TaxBasisRule};
use rust_decimal::Decimal; use rust_decimal::Decimal;
/// The register's date (None: today), kept across navigation. /// The register's date (None: today), kept across navigation.
@@ -48,6 +52,8 @@ pub struct Notice {
/// 2. unposted_depreciation() < 0, unregistered_cost() != 0: a problem /// 2. unposted_depreciation() < 0, unregistered_cost() != 0: a problem
/// each, with the exact amount. /// each, with the exact amount.
/// 3. Both zero: one note that the register agrees with the books. /// 3. Both zero: one note that the register agrees with the books.
/// 4. missing_tax_basis > 0: a problem, last, from
/// [`missing_tax_notice`].
/// ``` /// ```
pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> { pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
let mut out = Vec::new(); let mut out = Vec::new();
@@ -119,9 +125,154 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
), ),
}); });
} }
if let Some(text) = missing_tax_notice(r.missing_tax_basis) {
out.push(Notice {
problem: true,
text,
});
}
out out
} }
/// "Tax basis not recorded for 2 assets — ask the bookkeeper to record it
/// (books.set_asset_tax_basis).", or `None` when every asset held has one.
pub fn missing_tax_notice(missing: usize) -> Option<String> {
(missing > 0).then(|| {
format!(
"Tax basis not recorded for {} — ask the bookkeeper to record {} (books.set_asset_tax_basis): bought by the company, contributed by a member from business use, or converted from personal use.",
fmt::count(missing, "asset", "assets"),
if missing == 1 { "it" } else { "each" }
)
})
}
/// A tax basis rule for a sentence: "cost", "carryover", "personal-use
/// conversion".
pub fn rule_words(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "cost",
TaxBasisRule::Carryover => "carryover",
TaxBasisRule::PersonalUseConversion => "personal-use conversion",
}
}
/// What a rule means, in plain words, with the law it comes from.
pub fn rule_explained(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => {
"Bought by the company: its tax basis is what it paid (IRC §1012(a))."
}
TaxBasisRule::Carryover => {
"Contributed by a member from business use: it keeps the member's adjusted basis — what they paid less any depreciation they took (IRC §723)."
}
TaxBasisRule::PersonalUseConversion => {
"Contributed by a member from personal use: it depreciates from the lesser of its fair market value and the member's adjusted basis (Treas. Reg. §1.168(i)-4(b))."
}
}
}
/// Book value against tax basis, in one sentence, for an asset under
/// `rule` (or none recorded).
pub fn book_vs_tax(rule: Option<TaxBasisRule>) -> &'static str {
match rule {
Some(TaxBasisRule::Cost) => {
"Book cost and tax basis are both what the company paid; tax depreciation starts from the tax basis — the CPA's."
}
_ => {
"Book value is the fair market value at contribution; tax depreciation starts from the tax basis — the CPA's."
}
}
}
/// An asset's federal tax basis as the viewer shows it: every figure is
/// ledger-core's (`Asset::tax_basis`, `Asset::built_in_gain`), never
/// re-derived.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaxFacts {
/// The rule, if recorded.
pub rule: Option<TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable tax basis, if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, if any.
pub built_in_gain: Option<Decimal>,
}
impl TaxFacts {
/// An asset's.
pub fn of(a: &Asset) -> Self {
TaxFacts {
rule: a.tax.map(|t| t.rule),
contributor_basis: a.tax.and_then(|t| t.contributor_basis),
tax_basis: a.tax_basis(),
built_in_gain: a.built_in_gain(),
}
}
/// A register row's.
pub fn of_row(x: &AssetRegisterRow) -> Self {
TaxFacts {
rule: x.tax_rule,
contributor_basis: x.contributor_basis,
tax_basis: x.tax_basis,
built_in_gain: x.built_in_gain,
}
}
/// "Carryover · contributor's basis $900.00", or "Tax basis not
/// recorded": the line under an asset in the register.
pub fn summary(&self) -> String {
match self.rule {
None => "Tax basis not recorded".into(),
Some(r) => {
let mut s = ledger_import::asset_register::rule_label(r).to_string();
if let Some(b) = self.contributor_basis {
s.push_str(&format!(" · contributor's basis {}", fmt::dollars(b)));
}
s
}
}
}
/// Everything, with the rule's citation, for a tooltip: "Tax basis
/// $900.00: Carryover (IRC §723); contributor's basis $1,000.00;
/// built-in gain $100.00." or "Tax basis not recorded."
pub fn detail(&self) -> String {
let (Some(r), Some(t)) = (self.rule, self.tax_basis) else {
return "Tax basis not recorded.".into();
};
let mut s = format!(
"Tax basis {}: {}",
fmt::dollars(t),
ledger_import::asset_register::rule_cited(r)
);
if let Some(b) = self.contributor_basis {
s.push_str(&format!("; contributor's basis {}", fmt::dollars(b)));
}
if let Some(g) = self.built_in_gain {
let (label, amount) = gain_or_loss(g);
s.push_str(&format!(
"; {} {}",
label.to_lowercase(),
fmt::dollars(amount)
));
}
s.push('.');
s
}
}
/// "Built-in gain" or "Built-in loss", and the amount as a positive
/// figure, for a built-in gain (+) or loss (−).
pub fn gain_or_loss(g: Decimal) -> (&'static str, Decimal) {
if g < Decimal::ZERO {
("Built-in loss", -g)
} else {
("Built-in gain", g)
}
}
/// The status column: "Active", or "Disposed Mar 3, 2026 for $400.00". /// The status column: "Active", or "Disposed Mar 3, 2026 for $400.00".
pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String { pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String {
match disposed { match disposed {
@@ -219,13 +370,20 @@ fn row(
if flex_at == Some(i) if flex_at == Some(i)
&& let Some((s, c)) = sub && let Some((s, c)) = sub
{ {
// The second line may run under the next cell when that one
// is text (its own line is above).
let room = cw
+ cells
.get(i + 1)
.filter(|n| !n.2 && n.1 > 0.0)
.map_or(0.0, |n| n.1);
widgets::clipped_text( widgets::clipped_text(
&painter, &painter,
egui::pos2(x + 6.0, r.center().y + 9.0), egui::pos2(x + 6.0, r.center().y + 9.0),
s, s,
theme::regular(11.5), theme::regular(11.5),
c, c,
cw - 12.0, room - 12.0,
); );
} }
x += cw; x += cw;
@@ -301,22 +459,39 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let as_of = date_controls(ui, b, shell); let as_of = date_controls(ui, b, shell);
ui.add_space(12.0); ui.add_space(12.0);
let r = b.asset_register(as_of); let r = b.asset_register(as_of);
super::grid(ui, 3, 200.0, 3, |i, ui| { super::grid(ui, 4, 200.0, 4, |i, ui| {
let (label, value, how) = match i { let (label, value, how, sub) = match i {
0 => ( 0 => (
"Cost", "Cost",
r.total_cost, r.total_cost,
"What the assets still held cost, together.", "What the assets still held cost (for a member's contribution, its fair market value then), together.",
None,
), ),
1 => ( 1 => (
"Accumulated depreciation", "Accumulated depreciation",
r.total_accumulated, r.total_accumulated,
"Their depreciation by schedule through the date.", "Their book depreciation by schedule through the date.",
None,
), ),
_ => ( 2 => (
"Book value", "Book value",
r.total_book_value, r.total_book_value,
"Cost less accumulated depreciation.", "Cost less accumulated depreciation.",
None,
),
_ => (
"Tax basis",
r.total_tax_basis,
"The federal tax basis of the assets held that have one recorded: what tax depreciation (the CPA's) starts from.",
(r.missing_tax_basis > 0).then(|| {
(
format!(
"{} not recorded",
fmt::count(r.missing_tax_basis, "asset", "assets")
),
p.warning,
)
}),
), ),
}; };
widgets::kpi( widgets::kpi(
@@ -324,7 +499,7 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::Kpi { widgets::Kpi {
label, label,
value: fmt::dollars(value), value: fmt::dollars(value),
sub: None, sub,
spark: None, spark: None,
how, how,
}, },
@@ -346,7 +521,7 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
return; return;
} }
let amt = widgets::AMOUNT_MIN + 24.0; let amt = widgets::AMOUNT_MIN + 24.0;
let fixed = 110.0 + 104.0 + amt * 3.0 + 190.0; let fixed = 110.0 + 104.0 + amt * 5.0 + 190.0;
let w = ui.available_width().max(fixed + 200.0); let w = ui.available_width().max(fixed + 200.0);
widgets::min_width(ui, "asset_register", w, |ui| { widgets::min_width(ui, "asset_register", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
@@ -360,6 +535,8 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
head("Cost", amt, true), head("Cost", amt, true),
head("Accumulated", amt, true), head("Accumulated", amt, true),
head("Book value", amt, true), head("Book value", amt, true),
head("Tax basis", amt, true),
head("Built-in gain", amt, true),
head("Status", 190.0, false), head("Status", 190.0, false),
], ],
None, None,
@@ -377,7 +554,16 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
fmt::money(d) fmt::money(d)
} }
}; };
let sub = x.serial.as_ref().map(|s| format!("Serial {s}")); let tax = TaxFacts::of_row(x);
let sub = match &x.serial {
Some(s) => format!("Serial {s} · {}", tax.summary()),
None => tax.summary(),
};
let (tax_text, tax_color) = match tax.tax_basis {
_ if disposed => (String::new(), text),
Some(t) => (fmt::money(t), text),
None => ("not recorded".to_string(), p.warning),
};
let resp = row( let resp = row(
ui, ui,
w, w,
@@ -393,19 +579,27 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
(money(x.cost), amt, true, text), (money(x.cost), amt, true, text),
(money(x.accumulated), amt, true, text), (money(x.accumulated), amt, true, text),
(money(x.book_value), amt, true, text), (money(x.book_value), amt, true, text),
(tax_text, amt, true, tax_color),
(
x.built_in_gain.map(money).unwrap_or_default(),
amt,
true,
text,
),
(status_text(x.disposed), 190.0, false, p.muted), (status_text(x.disposed), 190.0, false, p.muted),
], ],
sub.as_deref().map(|s| (s, p.faint)), Some((sub.as_str(), p.faint)),
false, false,
true, true,
); );
let resp = resp.on_hover_text(if disposed { let resp = resp.on_hover_text(if disposed {
format!( format!(
"Open this asset (it cost {}; it left the books when disposed)", "Open this asset (it cost {}; it left the books when disposed). {}",
fmt::dollars(x.cost) fmt::dollars(x.cost),
tax.detail()
) )
} else { } else {
"Open this asset".to_string() format!("Open this asset. {}", tax.detail())
}); });
resp.context_menu(|ui| { resp.context_menu(|ui| {
if let Some(t) = &x.tag { if let Some(t) = &x.tag {
@@ -415,6 +609,9 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::copy_item(ui, "Copy serial number", s); widgets::copy_item(ui, "Copy serial number", s);
} }
widgets::copy_item(ui, "Copy book value", &x.book_value.to_string()); widgets::copy_item(ui, "Copy book value", &x.book_value.to_string());
if let Some(t) = x.tax_basis {
widgets::copy_item(ui, "Copy tax basis", &t.to_string());
}
}); });
if resp.clicked() { if resp.clicked() {
go = Some(x.asset_id); go = Some(x.asset_id);
@@ -431,6 +628,8 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
(fmt::money(r.total_cost), amt, true, p.text), (fmt::money(r.total_cost), amt, true, p.text),
(fmt::money(r.total_accumulated), amt, true, p.text), (fmt::money(r.total_accumulated), amt, true, p.text),
(fmt::money(r.total_book_value), amt, true, p.text), (fmt::money(r.total_book_value), amt, true, p.text),
(fmt::money(r.total_tax_basis), amt, true, p.text),
(String::new(), amt, true, p.text),
(String::new(), 190.0, false, p.text), (String::new(), 190.0, false, p.text),
], ],
None, None,
@@ -439,6 +638,17 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
); );
}); });
}); });
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(
"Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule (under each asset) and can differ. Tax depreciation starts from the tax basis — the CPA's.",
)
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
ui.add_space(14.0); ui.add_space(14.0);
reconciliation(ui, b, &r, shell); reconciliation(ui, b, &r, shell);
if let Some(id) = go { if let Some(id) = go {
@@ -777,6 +987,9 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
} }
}); });
tax_card(ui, a);
ui.add_space(14.0);
// The schedule. // The schedule.
let lines = schedule(b, id); let lines = schedule(b, id);
let due: Decimal = lines let due: Decimal = lines
@@ -1044,6 +1257,93 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
} }
} }
/// The asset's federal tax basis beside its book figures.
fn tax_card(ui: &mut Ui, a: &Asset) {
let p = pal(ui.ctx());
let t = TaxFacts::of(a);
widgets::titled_card(
ui,
"Tax basis",
|ui| {
ui.label(
RichText::new("Federal")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
let Some(rule) = t.rule else {
ui.label(
RichText::new("Not recorded")
.color(p.warning)
.font(theme::semibold(14.0)),
);
ui.add(
egui::Label::new(
RichText::new(
"Ask the bookkeeper to record it (books.set_asset_tax_basis): was it bought by the company, contributed by a member from business use, or converted from personal use? Book value is the fair market value at contribution; tax depreciation starts from the tax basis — the CPA's.",
)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
return;
};
facts(ui, |ui, k| {
let money = |ui: &mut Ui, d: Decimal, strong: bool| {
let s = fmt::dollars(d);
let rt = if strong {
RichText::new(&s).font(theme::semibold(14.0))
} else {
RichText::new(&s)
};
widgets::copyable(ui, rt, &d.to_string());
};
k(ui, "Rule");
ui.label(ledger_import::asset_register::rule_label(rule));
ui.end_row();
k(ui, "Book cost");
money(ui, a.cost.amount, false);
ui.end_row();
if let Some(c) = t.contributor_basis {
k(ui, "Contributor's basis");
money(ui, c, false);
ui.end_row();
}
if let Some(tb) = t.tax_basis {
k(ui, "Tax basis");
money(ui, tb, true);
ui.end_row();
}
if let Some(g) = t.built_in_gain {
let (label, amount) = gain_or_loss(g);
k(ui, label);
money(ui, amount, false);
ui.end_row();
}
});
ui.add_space(6.0);
let mut why = rule_explained(rule).to_string();
if t.built_in_gain.is_some_and(|g| g < Decimal::ZERO) {
why.push_str(" A built-in loss is taken into account only for the contributing member (IRC §704(c)(1)(C)).");
} else if t.built_in_gain.is_some_and(|g| g > Decimal::ZERO) {
why.push_str(" IRC §704(c) has the partnership take the built-in gain into account for the contributing member.");
}
for line in [why.as_str(), book_vs_tax(Some(rule))] {
ui.add(
egui::Label::new(
RichText::new(line)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
}
},
);
}
/// A two-column grid of facts; `add` gets the label painter. /// A two-column grid of facts; `add` gets the label painter.
fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) { fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
let p = pal(ui.ctx()); let p = pal(ui.ctx());
+44
View File
@@ -53,6 +53,45 @@ fn attachment_name(b: &Books, id: u64) -> String {
.unwrap_or_else(|| format!("attachment {id}")) .unwrap_or_else(|| format!("attachment {id}"))
} }
/// "Tax basis of WL-0001: personal-use conversion, contributor's basis
/// $1,917.21 → tax basis $1,650.00". The tax basis is ledger-core's
/// (`Asset::tax_basis`) for the event's rule and basis on the asset's
/// book cost, which never changes once registered.
fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
let asset = asset_id.and_then(|id| b.ledger.get_asset(id));
let name = match (asset, asset_id) {
(Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()),
(None, Some(id)) => format!("asset {id}"),
(None, None) => "an asset".to_string(),
};
let rule = ledger_core::TaxBasisRule::parse(s(p, "rule"));
let contributor: Option<rust_decimal::Decimal> = p
.get("contributor_basis")
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok());
let Some(rule) = rule else {
return format!("Tax basis of {name} recorded");
};
let tax = asset.and_then(|a| {
let mut a = a.clone();
a.tax = Some(ledger_core::TaxBasis {
rule,
contributor_basis: contributor,
});
a.tax_basis()
});
let mut text = format!("Tax basis of {name}: {}", super::assets::rule_words(rule));
if let Some(c) = contributor {
text.push_str(&format!(", contributor's basis {}", fmt::dollars(c)));
}
match (tax, contributor.is_some()) {
(Some(t), true) => text.push_str(&format!(" → tax basis {}", fmt::dollars(t))),
(Some(t), false) => text.push_str(&format!(", tax basis {}", fmt::dollars(t))),
(None, _) => {}
}
text
}
/// A sentence for a meaningful event; `None` for the intermediate steps of /// A sentence for a meaningful event; `None` for the intermediate steps of
/// building an entry (created, line added), which the posted event covers. /// building an entry (created, line added), which the posted event covers.
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> { pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
@@ -267,6 +306,11 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
}; };
ev(icon::BARCODE, text, asset_id.map(Route::Asset)) ev(icon::BARCODE, text, asset_id.map(Route::Asset))
} }
"asset.tax_basis_set" => ev(
icon::SCALES,
tax_basis_text(b, asset_id, p),
asset_id.map(Route::Asset),
),
"asset.described" => ev( "asset.described" => ev(
icon::PENCIL_SIMPLE, icon::PENCIL_SIMPLE,
format!( format!(
+105 -2
View File
@@ -945,14 +945,22 @@ fn aging(rows: Vec<ledger_core::AgingRow>, as_of: NaiveDate) -> Table {
/// The fixed asset register on `as_of`: ledger-core's /// The fixed asset register on `as_of`: ledger-core's
/// `fixed_asset_register`, laid out, with its reconciliation to the /// `fixed_asset_register`, laid out, with its reconciliation to the
/// general ledger and what that says (the Assets page's notices). /// general ledger and what that says (the Assets page's notices). Each
/// asset's federal tax basis sits beside its book figures: contributor's
/// basis, tax basis, and built-in gain (loss), the register row's own,
/// with its rule (and citation) in the detail; the tax basis total is the
/// register's.
fn asset_register(b: &Books, as_of: NaiveDate) -> Table { fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
let r = b.asset_register(as_of); let r = b.asset_register(as_of);
let mut t = Table::new(vec![ let mut t = Table::new(vec![
"Cost".into(), "Cost".into(),
"Accumulated".into(), "Accumulated".into(),
"Book value".into(), "Book value".into(),
"Contributor's basis".into(),
"Tax basis".into(),
"Built-in gain (loss)".into(),
]); ]);
let none3 = || vec![Cell::none(), Cell::none(), Cell::none()];
if r.rows.is_empty() { if r.rows.is_empty() {
t.note(format!( t.note(format!(
"No fixed asset was in service by {}.", "No fixed asset was in service by {}.",
@@ -975,6 +983,18 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
if !x.salvage.is_zero() { if !x.salvage.is_zero() {
detail.push(format!("salvage {}", fmt::dollars(x.salvage))); detail.push(format!("salvage {}", fmt::dollars(x.salvage)));
} }
let tax = super::assets::TaxFacts::of_row(x);
detail.push(match tax.rule {
Some(rule) => format!(
"tax basis: {}",
ledger_import::asset_register::rule_cited(rule)
),
None => "tax basis not recorded".into(),
});
let opt = |v: Option<Decimal>| Cell {
value: v,
drill: None,
};
let cells = match x.disposed { let cells = match x.disposed {
Some(_) => { Some(_) => {
detail.push(format!( detail.push(format!(
@@ -982,12 +1002,15 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
super::assets::status_text(x.disposed), super::assets::status_text(x.disposed),
fmt::dollars(x.cost) fmt::dollars(x.cost)
)); ));
vec![Cell::none(), Cell::none(), Cell::none()] [none3(), none3()].concat()
} }
None => vec![ None => vec![
Cell::of(x.cost), Cell::of(x.cost),
Cell::of(x.accumulated), Cell::of(x.accumulated),
Cell::of(x.book_value), Cell::of(x.book_value),
opt(tax.contributor_basis),
opt(tax.tax_basis),
opt(tax.built_in_gain),
], ],
}; };
t.rows.push(Row { t.rows.push(Row {
@@ -1004,6 +1027,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
Cell::of(r.total_cost), Cell::of(r.total_cost),
Cell::of(r.total_accumulated), Cell::of(r.total_accumulated),
Cell::of(r.total_book_value), Cell::of(r.total_book_value),
Cell::none(),
Cell::of(r.total_tax_basis),
Cell::none(),
], ],
); );
t.section("Reconciliation to the books"); t.section("Reconciliation to the books");
@@ -1039,6 +1065,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
drill: accum_drill, drill: accum_drill,
}, },
Cell::of(r.ledger_cost - r.ledger_accumulated), Cell::of(r.ledger_cost - r.ledger_accumulated),
Cell::none(),
Cell::none(),
Cell::none(),
], ],
); );
t.row( t.row(
@@ -1048,11 +1077,17 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
Cell::of(-r.unregistered_cost()), Cell::of(-r.unregistered_cost()),
Cell::of(r.unposted_depreciation()), Cell::of(r.unposted_depreciation()),
Cell::of(-r.unregistered_cost() - r.unposted_depreciation()), Cell::of(-r.unregistered_cost() - r.unposted_depreciation()),
Cell::none(),
Cell::none(),
Cell::none(),
], ],
); );
for n in super::assets::notices(b, &r) { for n in super::assets::notices(b, &r) {
t.note(n.text); t.note(n.text);
} }
t.note(
"Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule and can differ. Built-in gain (loss) is that value less the contributor's basis (IRC §704(c)). Tax depreciation starts from the tax basis — the CPA's.",
);
t t
} }
@@ -1998,6 +2033,74 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
mod tests { mod tests {
use super::*; use super::*;
/// The fixed asset register report's tax columns are the register's:
/// each held asset's contributor's basis, tax basis, and built-in gain
/// (ledger-core's Asset::tax_basis and built_in_gain), the total its
/// total_tax_basis, and its rule (cited) in the detail.
#[test]
fn asset_register_report_carries_tax_basis() {
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let dir = std::env::temp_dir().join(format!("ledger-gui-tax-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
})
.unwrap();
let r = ledger_core::fixed_asset_register(&b.ledger, today);
assert!(!r.rows.is_empty(), "the demo has an asset");
assert_eq!(r.missing_tax_basis, 0, "the demo's asset has a tax basis");
let t = asset_register(&b, today);
assert_eq!(
t.columns,
[
"Cost",
"Accumulated",
"Book value",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)"
]
);
for x in &r.rows {
let a = b.ledger.get_asset(x.asset_id).unwrap();
let row = t
.rows
.iter()
.find(|row| row.kind == Kind::Line && row.label.contains(&x.description))
.expect("a line per asset");
let v: Vec<Option<Decimal>> = row.cells.iter().map(|c| c.value).collect();
assert_eq!(
v,
[
Some(x.cost),
Some(x.accumulated),
Some(x.book_value),
a.tax.and_then(|t| t.contributor_basis),
a.tax_basis(),
a.built_in_gain(),
]
);
assert!(row.detail.contains("tax basis: Cost"), "{}", row.detail);
}
let total = t.rows.iter().find(|row| row.label == "Total held").unwrap();
assert_eq!(total.cells[4].value, Some(r.total_tax_basis));
assert_eq!(total.cells[4].value, r.rows[0].tax_basis);
assert!(
t.rows
.iter()
.all(|row| row.cells.is_empty() || row.cells.len() == 6)
);
assert!(
t.rows
.iter()
.any(|row| row.kind == Kind::Note && row.label.contains("fair market value"))
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Every table-backed report typesets as a PDF, in its plain, by-month, /// Every table-backed report typesets as a PDF, in its plain, by-month,
/// and compared layouts. Set `PDF_DIR` to keep the files for eyes. /// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
#[test] #[test]
+190 -6
View File
@@ -5,12 +5,12 @@
use chrono::{Datelike, Days, Months, NaiveDate}; use chrono::{Datelike, Days, Months, NaiveDate};
use egui_kittest::Harness; use egui_kittest::Harness;
use ledger_core::{ use ledger_core::{
AccountType, AssetId, Currency, EntryId, Money, NewAsset, Side, depreciation_schedule, AccountType, AssetId, Currency, EntryId, Money, NewAsset, Side, TaxBasis, TaxBasisRule,
fixed_asset_register, depreciation_schedule, fixed_asset_register,
}; };
use ledger_gui::books::Books; use ledger_gui::books::Books;
use ledger_gui::nav::{ReportKind, Route}; use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::pages::assets::{MonthStatus, notices, schedule}; use ledger_gui::pages::assets::{MonthStatus, TaxFacts, missing_tax_notice, notices, schedule};
use ledger_gui::pages::journal::describe; use ledger_gui::pages::journal::describe;
use ledger_gui::{App, Source, attention, data, fmt, palette}; use ledger_gui::{App, Source, attention, data, fmt, palette};
use ledger_store::Store; use ledger_store::Store;
@@ -77,9 +77,11 @@ fn today() -> NaiveDate {
d(TODAY.0, TODAY.1, TODAY.2) d(TODAY.0, TODAY.1, TODAY.2)
} }
/// A laptop (tagged, serial, depreciation through July), a monitor (no /// A laptop (tagged, serial, depreciation through July; tax basis first
/// tag, through August), and an old laptop retagged, its serial cleared, /// recorded as a carryover, then corrected to cost), a monitor (no tag, no
/// and sold in June. /// tax basis, through August), and an old laptop retagged, its serial
/// cleared, converted from personal use worth less than its basis (a
/// built-in loss), and sold in June.
fn fixture(tag: &str) -> Fixture { fn fixture(tag: &str) -> Fixture {
let dir = TempDir::new(tag); let dir = TempDir::new(tag);
let data = dir.0.join("books-data"); let data = dir.0.join("books-data");
@@ -145,6 +147,16 @@ fn fixture(tag: &str) -> Fixture {
s.set_asset_tag(old, "WL-0009").unwrap(); s.set_asset_tag(old, "WL-0009").unwrap();
s.set_asset_serial(old, Some("X1")).unwrap(); s.set_asset_serial(old, Some("X1")).unwrap();
s.set_asset_serial(old, None).unwrap(); s.set_asset_serial(old, None).unwrap();
let tax = |rule, basis: Option<i64>| TaxBasis {
rule,
contributor_basis: basis.map(|c| Decimal::new(c, 2)),
};
s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Carryover, Some(200_000)))
.unwrap();
s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Cost, None))
.unwrap();
s.set_asset_tax_basis(old, tax(TaxBasisRule::PersonalUseConversion, Some(70_000)))
.unwrap();
for m in 1..=7 { for m in 1..=7 {
s.run_depreciation(laptop, &format!("2026-{m:02}")).unwrap(); s.run_depreciation(laptop, &format!("2026-{m:02}")).unwrap();
} }
@@ -439,6 +451,178 @@ fn attention_asks_for_depreciation_and_tags() {
assert_eq!(b.depreciation_due(today()).amount, Decimal::ZERO); assert_eq!(b.depreciation_due(today()).amount, Decimal::ZERO);
} }
/// The viewer's tax figures are ledger-core's: each asset's
/// (Asset::tax_basis, Asset::built_in_gain), each register row's, and the
/// register's total and missing count, on every date.
#[test]
fn tax_basis_is_ledger_cores() {
let f = fixture("tax");
let b = &f.books;
for id in [f.laptop, f.monitor, f.old] {
let core = f.oracle.get_asset(id).unwrap();
let mine = TaxFacts::of(b.ledger.get_asset(id).unwrap());
assert_eq!(mine.rule, core.tax.map(|t| t.rule), "asset {id}");
assert_eq!(
mine.contributor_basis,
core.tax.and_then(|t| t.contributor_basis)
);
assert_eq!(mine.tax_basis, core.tax_basis(), "asset {id}");
assert_eq!(mine.built_in_gain, core.built_in_gain(), "asset {id}");
}
// Independently: the laptop's basis is its cost; the old laptop's is
// its value ($600.00, under the member's $700.00), a $100.00 loss.
let laptop = TaxFacts::of(b.ledger.get_asset(f.laptop).unwrap());
assert_eq!(laptop.rule, Some(TaxBasisRule::Cost));
assert_eq!(laptop.tax_basis, Some(Decimal::new(300_000, 2)));
assert_eq!(laptop.built_in_gain, None);
let old = TaxFacts::of(b.ledger.get_asset(f.old).unwrap());
assert_eq!(old.tax_basis, Some(Decimal::new(60_000, 2)));
assert_eq!(old.built_in_gain, Some(Decimal::new(-10_000, 2)));
assert_eq!(
old.summary(),
"Personal-use conversion · contributor's basis $700.00"
);
assert_eq!(
old.detail(),
"Tax basis $600.00: Personal-use conversion (Treas. Reg. §1.168(i)-4(b)); contributor's basis $700.00; built-in loss $100.00."
);
assert_eq!(
TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()).detail(),
"Tax basis not recorded."
);
assert_eq!(
TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()),
TaxFacts {
rule: None,
contributor_basis: None,
tax_basis: None,
built_in_gain: None
}
);
for as_of in [d(2026, 1, 31), d(2026, 6, 30), today()] {
let r = b.asset_register(as_of);
let oracle = fixed_asset_register(&f.oracle, as_of);
assert_eq!(r.total_tax_basis, oracle.total_tax_basis, "{as_of}");
assert_eq!(r.missing_tax_basis, oracle.missing_tax_basis, "{as_of}");
for x in &r.rows {
let a = f.oracle.get_asset(x.asset_id).unwrap();
let facts = TaxFacts::of_row(x);
assert_eq!(facts.tax_basis, a.tax_basis());
assert_eq!(facts.built_in_gain, a.built_in_gain());
assert_eq!(facts, TaxFacts::of(a));
}
}
// Today: the laptop's $3,000.00 (the monitor has none; the old laptop
// is gone), and one missing, said in the notices.
let r = b.asset_register(today());
assert_eq!(r.total_tax_basis, Decimal::new(300_000, 2));
assert_eq!(r.missing_tax_basis, 1);
let want = missing_tax_notice(1).unwrap();
assert!(want.starts_with("Tax basis not recorded for 1 asset"));
assert!(want.contains("books.set_asset_tax_basis"));
assert!(notices(b, &r).iter().any(|n| n.problem && n.text == want));
assert_eq!(missing_tax_notice(0), None);
assert!(
missing_tax_notice(2)
.unwrap()
.starts_with("Tax basis not recorded for 2 assets")
);
}
#[test]
fn journal_text_for_tax_basis() {
let f = fixture("tax-journal");
let b = &f.books;
let texts: Vec<(String, Option<Route>)> = b
.journal
.iter()
.filter(|l| l.line_type == "asset.tax_basis_set")
.filter_map(|l| describe(b, l))
.map(|e| (e.text, e.route))
.collect();
assert_eq!(
texts,
vec![
(
"Tax basis of WL-0001: carryover, contributor's basis $2,000.00 → tax basis $2,000.00"
.to_string(),
Some(Route::Asset(f.laptop))
),
(
"Tax basis of WL-0001: cost, tax basis $3,000.00".to_string(),
Some(Route::Asset(f.laptop))
),
(
"Tax basis of WL-0009: personal-use conversion, contributor's basis $700.00 → tax basis $600.00"
.to_string(),
Some(Route::Asset(f.old))
),
]
);
}
/// An asset held with no tax basis asks for one, with a prompt naming
/// the tool and the asset; recording it clears the item.
#[test]
fn attention_asks_for_tax_basis() {
let f = fixture("tax-attention");
let items = attention::find(&f.books);
let tax: Vec<_> = items
.iter()
.filter(|a| a.title.starts_with("No tax basis"))
.collect();
assert_eq!(tax.len(), 1, "only the monitor: {tax:#?}");
let item = tax[0];
assert_eq!(item.title, "No tax basis: Monitor");
assert_eq!(item.target, Some(Route::Asset(f.monitor)));
assert!(item.prompt.starts_with(&format!(
"In wanderbooks, record the tax basis of asset {} (\"Monitor\", book cost $1,200.00) with books.set_asset_tax_basis.",
f.monitor
)));
assert!(item.detail.contains("$1,200.00"));
let data = f._dir.0.join("books-data");
let mut s = Store::open(&data).unwrap();
s.set_asset_tag(f.monitor, "WL-0002").unwrap();
drop(s);
let b = load(&f._dir.0, today());
let item = attention::find(&b)
.into_iter()
.find(|a| a.title.starts_with("No tax basis"))
.expect("still missing");
assert_eq!(item.title, "No tax basis: WL-0002 Monitor");
assert!(
item.prompt
.starts_with("In wanderbooks, record the tax basis of WL-0002 (")
);
let mut s = Store::open(&data).unwrap();
s.set_asset_tax_basis(
f.monitor,
TaxBasis {
rule: TaxBasisRule::Carryover,
contributor_basis: Some(Decimal::new(90_000, 2)),
},
)
.unwrap();
drop(s);
let b = load(&f._dir.0, today());
assert!(
!attention::find(&b)
.iter()
.any(|a| a.title.starts_with("No tax basis"))
);
let r = b.asset_register(today());
assert_eq!(r.missing_tax_basis, 0);
assert!(
!notices(&b, &r)
.iter()
.any(|n| n.text.contains("Tax basis not recorded"))
);
// $3,000.00 laptop + the monitor's $900.00 carryover.
assert_eq!(r.total_tax_basis, Decimal::new(390_000, 2));
}
#[test] #[test]
fn unregistered_equipment_is_flagged() { fn unregistered_equipment_is_flagged() {
let f = fixture("unregistered"); let f = fixture("unregistered");
+242 -13
View File
@@ -28,11 +28,28 @@
//! 5. Header and footer as on every statement: company, "Fixed Asset //! 5. Header and footer as on every statement: company, "Fixed Asset
//! Register", "As of DATE"; basis, prepared on, journal seq. //! Register", "As of DATE"; basis, prepared on, journal seq.
//! 6. PDF: deterministic bytes; every text is data, never markup. //! 6. PDF: deterministic bytes; every text is data, never markup.
//! 7. Tax basis beside book value: every line carries its tax basis
//! rule (in words: [`rule_label`], [`rule_cited`]), the contributor's
//! basis, the tax basis, and the built-in gain (loss), each the
//! register row's own (ledger_core::Asset::tax_basis,
//! Asset::built_in_gain), never re-derived here; a line with none
//! recorded says "Not recorded" and leaves the amounts blank.
//! 8. The tax basis total is the register's total_tax_basis; when
//! missing_tax_basis > 0 a line says "Tax basis not recorded for N
//! asset(s)" (see [`missing_line`]).
//! 9. The notes ([`notes`]) say that book value is fair market value at
//! contribution and tax basis follows its rule, and cite each rule
//! (IRC §1012(a), §723, Treas. Reg. §1.168(i)-4(b), IRC §704(c)).
//! The PDF marks each rule in the tax table with its note's number.
//! ``` //! ```
//!
//! SOURCE (7-9): docs/spec/rules.md B-25;
//! docs/spec/sources/irs-partnership-basis.md.
use crate::pdf::{INTER, PdfError}; use crate::pdf::{INTER, PdfError};
use crate::report::{ReportMeta, amount_parts, format_amount, long_date}; use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
use crate::xlsx::{Formats, XlsxError, footer, header, number}; use crate::xlsx::{Formats, XlsxError, footer, header, number};
use ledger_core::TaxBasisRule;
use ledger_core::reports::{AssetRegister, AssetRegisterRow}; use ledger_core::reports::{AssetRegister, AssetRegisterRow};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatAlign, Workbook}; use rust_xlsxwriter::{Format, FormatAlign, Workbook};
@@ -58,9 +75,135 @@ pub const COLUMNS: [&str; 10] = [
"Status", "Status",
]; ];
/// The PDF's tax basis table's headings, left to right.
pub const TAX_COLUMNS: [&str; 7] = [
"Tag",
"Description",
"Tax basis rule",
"Book cost",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)",
];
/// The XLSX headings, left to right: the book columns, the tax columns,
/// then the status.
pub const XLSX_COLUMNS: [&str; 14] = [
"Tag",
"Description",
"Serial",
"In service",
"Cost",
"Salvage",
"Life (months)",
"Accumulated depreciation",
"Book value",
"Tax basis rule",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)",
"Status",
];
/// The line shown when there are no assets. /// The line shown when there are no assets.
pub const EMPTY: &str = "No fixed assets"; pub const EMPTY: &str = "No fixed assets";
/// The tax table's title.
pub const TAX_TITLE: &str = "Federal tax basis";
/// The rule cell of an asset with no tax basis recorded.
pub const NOT_RECORDED: &str = "Not recorded";
/// A rule in short words, for the table: "Cost", "Carryover",
/// "Personal-use conversion".
pub fn rule_label(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "Cost",
TaxBasisRule::Carryover => "Carryover",
TaxBasisRule::PersonalUseConversion => "Personal-use conversion",
}
}
/// A rule in words with its citation: "Cost", "Carryover (IRC §723)",
/// "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))".
pub fn rule_cited(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "Cost",
TaxBasisRule::Carryover => "Carryover (IRC §723)",
TaxBasisRule::PersonalUseConversion => {
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
}
}
}
/// The number of a rule's note in [`notes`].
pub fn rule_mark(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "1",
TaxBasisRule::Carryover => "2",
TaxBasisRule::PersonalUseConversion => "3",
}
}
/// One note under the register.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
/// Its number, as the tax table marks it, or empty for a general note.
pub mark: String,
/// What it says.
pub text: String,
}
/// The notes (INVARIANT 9): book value against tax basis, then each
/// rule's citation and the built-in gain (loss).
pub fn notes() -> Vec<Note> {
let n = |mark: &str, text: &str| Note {
mark: mark.to_string(),
text: text.to_string(),
};
vec![
n(
"",
"Book value is the fair market value at contribution for property a member contributed, \
and the price paid for property the company bought; tax basis follows its rule and can \
differ. Depreciation here is book depreciation; tax depreciation starts from the tax \
basis and is the CPA's.",
),
n(
rule_mark(TaxBasisRule::Cost),
"Cost: property the company bought has a basis of its cost (IRC §1012(a)).",
),
n(
rule_mark(TaxBasisRule::Carryover),
"Carryover (IRC §723): property a member contributes keeps the member's adjusted \
basis at contribution (their cost less any depreciation they took).",
),
n(
rule_mark(TaxBasisRule::PersonalUseConversion),
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted from \
personal use depreciates from the lesser of its fair market value and the member's \
adjusted basis at conversion.",
),
n(
"4",
"Built-in gain (loss): fair market value at contribution less the contributor's basis \
(IRC §704(c)(1)(A)); a built-in loss is taken into account only for the contributing \
member (IRC §704(c)(1)(C)).",
),
]
}
/// "Tax basis not recorded for 1 asset" / "... for 2 assets", or `None`
/// when every asset held has one.
pub fn missing_line(missing: usize) -> Option<String> {
(missing > 0).then(|| {
format!(
"Tax basis not recorded for {missing} {}",
if missing == 1 { "asset" } else { "assets" }
)
})
}
/// One asset's line. /// One asset's line.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetLine { pub struct AssetLine {
@@ -86,6 +229,14 @@ pub struct AssetLine {
pub status: String, pub status: String,
/// Disposed by the date (shown, not in the totals). /// Disposed by the date (shown, not in the totals).
pub disposed: bool, pub disposed: bool,
/// The tax basis rule, if recorded.
pub tax_rule: Option<TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable federal tax basis, if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, if any.
pub built_in_gain: Option<Decimal>,
} }
/// Which register column a reconciliation amount sits under. /// Which register column a reconciliation amount sits under.
@@ -122,6 +273,12 @@ pub struct RegisterLayout {
/// Total cost, accumulated depreciation, and book value of the assets /// Total cost, accumulated depreciation, and book value of the assets
/// held. /// held.
pub totals: [Decimal; 3], pub totals: [Decimal; 3],
/// Total tax basis of the assets held that have one.
pub tax_total: Decimal,
/// [`missing_line`] of the register's missing_tax_basis.
pub missing: Option<String>,
/// [`notes`].
pub notes: Vec<Note>,
/// Cost then accumulated depreciation: per register, per books, /// Cost then accumulated depreciation: per register, per books,
/// difference. /// difference.
pub reconciliation: Vec<ReconLine>, pub reconciliation: Vec<ReconLine>,
@@ -162,9 +319,16 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
book_value: a.book_value, book_value: a.book_value,
status: status(a), status: status(a),
disposed: a.disposed.is_some(), disposed: a.disposed.is_some(),
tax_rule: a.tax_rule,
contributor_basis: a.contributor_basis,
tax_basis: a.tax_basis,
built_in_gain: a.built_in_gain,
}) })
.collect(), .collect(),
totals: [r.total_cost, r.total_accumulated, r.total_book_value], totals: [r.total_cost, r.total_accumulated, r.total_book_value],
tax_total: r.total_tax_basis,
missing: missing_line(r.missing_tax_basis),
notes: notes(),
reconciliation: vec![ reconciliation: vec![
recon("Cost per register", r.total_cost, ReconColumn::Cost, false), recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false), recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false),
@@ -199,20 +363,25 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
/// The fixed asset register as XLSX bytes. /// The fixed asset register as XLSX bytes.
/// ///
/// Layout as every statement (crate::xlsx INVARIANT 4): company, title, /// Layout as every statement (crate::xlsx INVARIANT 4): company, title,
/// and date on rows 1-3, headings on row 5, one row per asset from row 6, /// and date on rows 1-3, headings ([`XLSX_COLUMNS`]) on row 5, one row
/// amounts as numbers in the accounting format; then the totals row /// per asset from row 6, amounts as numbers in the accounting format, the
/// (double rule), an empty row, "Reconciliation to the books", and its /// tax rule in words with its citation ([`rule_cited`]); then the totals
/// lines with amounts under Cost and Accumulated depreciation. /// row (double rule) with the tax basis total under Tax basis, the
/// missing tax basis line when there is one, an empty row, "Reconciliation
/// to the books" and its lines with amounts under Cost and Accumulated
/// depreciation, an empty row, and "Notes" with [`notes`].
pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> { pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
const AMOUNTS: [u16; 7] = [4, 5, 7, 8, 10, 11, 12];
const TOTALS: [u16; 4] = [4, 7, 8, 11];
let l = layout(r); let l = layout(r);
let mut workbook = Workbook::new(); let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet(); let sheet = workbook.add_worksheet();
let f = Formats::new(); let f = Formats::new();
sheet.set_name(TITLE)?; sheet.set_name(TITLE)?;
let width = COLUMNS.len() as u16; let width = XLSX_COLUMNS.len() as u16;
let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?; let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?;
for (i, h) in COLUMNS.iter().enumerate() { for (i, h) in XLSX_COLUMNS.iter().enumerate() {
let fmt = if (4..=8).contains(&i) { let fmt = if AMOUNTS.contains(&(i as u16)) || i == 6 {
&f.header_right &f.header_right
} else { } else {
&f.header &f.header
@@ -226,6 +395,7 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
} }
let quiet = Format::new().set_italic().set_font_color("#666666"); let quiet = Format::new().set_italic().set_font_color("#666666");
let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666"); let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666");
let missing = Format::new().set_italic().set_font_color("#9A5B00");
for a in &l.lines { for a in &l.lines {
let (text, amount) = if a.disposed { let (text, amount) = if a.disposed {
(&quiet, &quiet_plain) (&quiet, &quiet_plain)
@@ -246,17 +416,43 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
)?; )?;
number(sheet, row, 7, a.accumulated, amount)?; number(sheet, row, 7, a.accumulated, amount)?;
number(sheet, row, 8, a.book_value, amount)?; number(sheet, row, 8, a.book_value, amount)?;
sheet.write_string_with_format(row, 9, &a.status, text)?; match a.tax_rule {
Some(rule) => sheet.write_string_with_format(row, 9, rule_cited(rule), text)?,
None => sheet.write_string_with_format(
row,
9,
NOT_RECORDED,
if a.disposed { text } else { &missing },
)?,
};
for (c, v) in [
(10, a.contributor_basis),
(11, a.tax_basis),
(12, a.built_in_gain),
] {
if let Some(v) = v {
number(sheet, row, c, v, amount)?;
}
}
sheet.write_string_with_format(row, 13, &a.status, text)?;
row += 1; row += 1;
} }
sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?; sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?;
for c in (1..width).filter(|c| ![4, 7, 8].contains(c)) { for c in (1..width).filter(|c| !TOTALS.contains(c)) {
sheet.write_blank(row, c, &f.dollars_total)?; sheet.write_blank(row, c, &f.dollars_total)?;
} }
for (c, d) in [4u16, 7, 8].into_iter().zip(l.totals) { for (c, d) in TOTALS
.into_iter()
.zip(l.totals.into_iter().chain([l.tax_total]))
{
number(sheet, row, c, d, &f.dollars_total)?; number(sheet, row, c, d, &f.dollars_total)?;
} }
row += 2; row += 1;
if let Some(m) = &l.missing {
sheet.write_string_with_format(row, 0, m, &f.sub_label)?;
row += 1;
}
row += 1;
sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?; sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?;
row += 1; row += 1;
for line in &l.reconciliation { for line in &l.reconciliation {
@@ -276,6 +472,17 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
row += 1; row += 1;
} }
} }
sheet.write_string_with_format(row, 0, "Notes", &f.bold)?;
row += 1;
for n in &l.notes {
let text = if n.mark.is_empty() {
n.text.clone()
} else {
format!("{}. {}", n.mark, n.text)
};
sheet.write_string_with_format(row, 0, text, &f.line_label)?;
row += 1;
}
for (c, w) in [ for (c, w) in [
(0, 12.0), (0, 12.0),
(1, 40.0), (1, 40.0),
@@ -286,11 +493,15 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
(6, 9.0), (6, 9.0),
(7, 16.0), (7, 16.0),
(8, 14.0), (8, 14.0),
(9, 34.0), (9, 30.0),
(10, 14.0),
(11, 14.0),
(12, 14.0),
(13, 34.0),
] { ] {
sheet.set_column_width(c, w)?; sheet.set_column_width(c, w)?;
} }
footer(sheet, &f, meta, row.saturating_sub(1), width)?; footer(sheet, &f, meta, row, width)?;
Ok(workbook.save_to_buffer()?) Ok(workbook.save_to_buffer()?)
} }
@@ -311,6 +522,10 @@ fn amount(d: Decimal) -> serde_json::Value {
serde_json::json!({ "digits": p.digits, "negative": p.negative }) serde_json::json!({ "digits": p.digits, "negative": p.negative })
} }
fn maybe(d: Option<Decimal>) -> serde_json::Value {
d.map_or(serde_json::Value::Null, amount)
}
/// The PDF template's input: the layout, every amount split for /// The PDF template's input: the layout, every amount split for
/// alignment. /// alignment.
pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value { pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value {
@@ -321,6 +536,8 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
"period": l.period, "period": l.period,
"footer": meta.footer(), "footer": meta.footer(),
"columns": COLUMNS, "columns": COLUMNS,
"tax_title": TAX_TITLE,
"tax_columns": TAX_COLUMNS,
"empty": EMPTY, "empty": EMPTY,
"lines": l.lines.iter().map(|a| serde_json::json!({ "lines": l.lines.iter().map(|a| serde_json::json!({
"tag": a.tag, "tag": a.tag,
@@ -334,8 +551,20 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
"book_value": amount(a.book_value), "book_value": amount(a.book_value),
"status": a.status, "status": a.status,
"disposed": a.disposed, "disposed": a.disposed,
"rule": a.tax_rule.map_or(NOT_RECORDED, rule_label),
"rule_mark": a.tax_rule.map_or("", rule_mark),
"recorded": a.tax_rule.is_some(),
"contributor_basis": maybe(a.contributor_basis),
"tax_basis": maybe(a.tax_basis),
"built_in_gain": maybe(a.built_in_gain),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(), "totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
"tax_total": amount(l.tax_total),
"missing": l.missing,
"notes": l.notes.iter().map(|n| serde_json::json!({
"mark": n.mark,
"text": n.text,
})).collect::<Vec<_>>(),
"reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({ "reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({
"label": x.label, "label": x.label,
"amount": amount(x.amount), "amount": amount(x.amount),
@@ -1,15 +1,21 @@
// The fixed asset register: every asset, its cost, depreciation, and book // The fixed asset register: every asset, its cost, depreciation, and book
// value, then the totals and the reconciliation to the general ledger. // value, then the totals and the reconciliation to the general ledger,
// then each asset's federal tax basis beside its book cost, and the notes
// that cite the tax basis rules.
// //
// Everything arrives as data in sys.inputs.data (JSON), laid out by // Everything arrives as data in sys.inputs.data (JSON), laid out by
// ledger-import::asset_register, with amounts already split into exact // ledger-import::asset_register, with amounts already split into exact
// parts. The template only typesets: no arithmetic, and no text is ever // parts. The template only typesets: no arithmetic, and no text is ever
// parsed as markup. // parsed as markup.
// //
// Shape: { company, title, period, footer, columns: [10 headings], empty, // Shape: { company, title, period, footer, columns: [10 headings],
// tax_title, tax_columns: [7 headings], empty,
// lines: [{ tag, description, serial, in_service, cost, salvage, // lines: [{ tag, description, serial, in_service, cost, salvage,
// life, accumulated, book_value, status, disposed }], // life, accumulated, book_value, status, disposed,
// totals: [cost, accumulated, book_value], // rule, rule_mark, recorded, contributor_basis|null,
// tax_basis|null, built_in_gain|null }],
// totals: [cost, accumulated, book_value], tax_total,
// missing: text|null, notes: [{ mark, text }],
// reconciliation: [{ label, amount, difference }] } // reconciliation: [{ label, amount, difference }] }
// where every amount is { digits: "1,234.56", negative }. // where every amount is { digits: "1,234.56", negative }.
@@ -121,8 +127,8 @@
), ),
) )
#v(14pt) // The reconciliation to the books, drawn beside the notes at the end.
#block(breakable: false, { #let reconciliation = block(breakable: false, {
text(weight: "semibold", "Reconciliation to the books") text(weight: "semibold", "Reconciliation to the books")
v(2pt) v(2pt)
table( table(
@@ -147,3 +153,86 @@
.flatten(), .flatten(),
) )
}) })
// Federal tax basis, asset by asset, beside the book cost it may differ
// from; each rule marked with the number of the note that cites it.
#let tax_numeric = (3, 4, 5, 6)
#let tcols = d.tax_columns.len()
#let maybe(x) = if x == none { [] } else { amount(x) }
#v(12pt)
#text(weight: "semibold", d.tax_title)
#v(2pt)
#table(
columns: (auto, 1fr, auto, 7.4em, 7.4em, 7.4em, 7.4em),
stroke: none,
inset: (x: 3.5pt, y: 3.4pt),
align: (col, row) => (if col in tax_numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
table.header(
..d.tax_columns.enumerate().map(((i, c)) => table.cell(
stroke: (top: firm, bottom: hair),
text(6.5pt, weight: "bold", tracking: 0.04em, {
upper(c)
if i == tcols - 1 { super("4") }
}),
)),
),
..if d.lines.len() == 0 {
(table.cell(colspan: tcols, emph(d.empty)),)
} else {
d
.lines
.map(l => {
let t(s) = if l.disposed { text(fill: quiet, s) } else { s }
let rule = if l.recorded {
[#l.rule#super(l.rule_mark)]
} else if l.disposed {
emph(l.rule)
} else {
text(fill: rgb("#9a5b00"), emph(l.rule))
}
(
t(l.tag),
t(l.description),
t(rule),
t(amount(l.cost)),
t(maybe(l.contributor_basis)),
t(maybe(l.tax_basis)),
t(maybe(l.built_in_gain)),
)
})
.flatten()
},
..{
let s = (top: firm)
let blank = table.cell(stroke: s, [])
(
table.cell(stroke: s, colspan: 5, text(weight: "bold", "Total tax basis, assets held")),
table.cell(stroke: s, text(weight: "bold", amount(d.tax_total, currency: true))),
blank,
)
},
table.cell(
colspan: tcols,
inset: (x: 0pt, y: 0pt),
stack(spacing: 1.6pt, line(length: 100%, stroke: firm), line(length: 100%, stroke: firm)),
),
)
#if d.missing != none {
v(4pt)
text(fill: rgb("#9a5b00"), emph(d.missing))
}
// Notes: book value against tax basis, and each rule's citation.
#let notes = {
set text(7.5pt, fill: luma(70))
set par(leading: 0.5em)
text(weight: "semibold", fill: ink, "Notes")
block(above: 6pt, d.notes.at(0).text)
for n in d.notes.slice(1) {
block(above: 4pt, grid(columns: (1.2em, 1fr), super(n.mark), n.text))
}
}
// The reconciliation and the notes, side by side.
#v(12pt)
#grid(columns: (auto, 1fr), column-gutter: 32pt, reconciliation, pad(top: 1pt, notes))
+139 -8
View File
@@ -4,8 +4,10 @@
//! INVARIANT it checks. //! INVARIANT it checks.
use chrono::NaiveDate; use chrono::NaiveDate;
use ledger_core::{AssetRegister, AssetRegisterRow}; use ledger_core::{AssetRegister, AssetRegisterRow, TaxBasisRule};
use ledger_import::asset_register::{layout, typeset_asset_register}; use ledger_import::asset_register::{
layout, missing_line, notes, rule_cited, rule_label, typeset_asset_register,
};
use ledger_import::report::ReportMeta; use ledger_import::report::ReportMeta;
use ledger_import::{PdfError, asset_register_pdf}; use ledger_import::{PdfError, asset_register_pdf};
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -41,14 +43,43 @@ fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow
accumulated: cents(acc), accumulated: cents(acc),
book_value: cents(cost - acc), book_value: cents(cost - acc),
disposed: None, disposed: None,
tax_rule: None,
contributor_basis: None,
tax_basis: None,
built_in_gain: None,
} }
} }
/// Bought: tax basis is cost.
fn bought(mut r: AssetRegisterRow) -> AssetRegisterRow {
r.tax_rule = Some(TaxBasisRule::Cost);
r.tax_basis = Some(r.cost);
r
}
/// Contributed under `rule` with the member's basis `basis` (cents); tax
/// basis and built-in gain as ledger_core::Asset computes them.
fn contributed(mut r: AssetRegisterRow, rule: TaxBasisRule, basis: i64) -> AssetRegisterRow {
let b = cents(basis);
r.tax_rule = Some(rule);
r.contributor_basis = Some(b);
r.tax_basis = Some(match rule {
TaxBasisRule::PersonalUseConversion => b.min(r.cost),
_ => b,
});
r.built_in_gain = Some(r.cost - b);
r
}
fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister { fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister {
let held = || rows.iter().filter(|r| r.disposed.is_none()); let held = || rows.iter().filter(|r| r.disposed.is_none());
let total_cost = held().map(|r| r.cost).sum(); let total_cost = held().map(|r| r.cost).sum();
let total_accumulated = held().map(|r| r.accumulated).sum(); let total_accumulated = held().map(|r| r.accumulated).sum();
let total_tax_basis = held().filter_map(|r| r.tax_basis).sum();
let missing_tax_basis = held().filter(|r| r.tax_basis.is_none()).count();
AssetRegister { AssetRegister {
total_tax_basis,
missing_tax_basis,
as_of: d(2026, 10, 15), as_of: d(2026, 10, 15),
total_book_value: total_cost - total_accumulated, total_book_value: total_cost - total_accumulated,
ledger_cost: total_cost, ledger_cost: total_cost,
@@ -73,9 +104,23 @@ fn sample() -> AssetRegister {
laptop.serial = Some("FRANBMCP0A1234".into()); laptop.serial = Some("FRANBMCP0A1234".into());
laptop.salvage = cents(10000); laptop.salvage = cents(10000);
register(vec![ register(vec![
laptop, bought(laptop),
row(2, "WL-0002", "MacBook Air", 129900, 32475), // A member's business laptop: carryover basis, built-in gain.
disposed, contributed(
row(2, "WL-0002", "MacBook Air", 129900, 32475),
TaxBasisRule::Carryover,
90000,
),
bought(disposed),
// A member's personal camera: worth less than its basis, so tax
// basis is the fair market value and there's a built-in loss.
contributed(
row(4, "WL-0004", "Sony A7 IV camera", 165000, 0),
TaxBasisRule::PersonalUseConversion,
191721,
),
// No tax basis recorded yet.
row(5, "WL-0005", "Standing desk", 80000, 0),
]) ])
} }
@@ -126,9 +171,9 @@ fn pdf_shows_assets_totals_and_reconciliation() {
"Active", "Active",
"Disposed 2026-09-30, proceeds 45.00", "Disposed 2026-09-30, proceeds 45.00",
"Total, assets held", "Total, assets held",
"3,799.00", // cost held: 2,500.00 + 1,299.00 "6,249.00", // cost held: 2,500.00 + 1,299.00 + 1,650.00 + 800.00
"852.53", // accumulated held "852.53", // accumulated held
"2,946.47", // book value held "5,396.47", // book value held
"Cost per register", "Cost per register",
"Cost per books", "Cost per books",
"Difference (unregistered cost)", "Difference (unregistered cost)",
@@ -143,7 +188,93 @@ fn pdf_shows_assets_totals_and_reconciliation() {
// The disposed asset's cost (400.00) is shown but in no total. // The disposed asset's cost (400.00) is shown but in no total.
assert!(t.contains("400.00")); assert!(t.contains("400.00"));
let l = layout(&sample()); let l = layout(&sample());
assert_eq!(l.totals, [cents(379900), cents(85253), cents(294647)]); assert_eq!(l.totals, [cents(624900), cents(85253), cents(539647)]);
}
/// INVARIANTS 7-9: the tax table shows each rule in short words, the
/// contributor's basis, the tax basis, the built-in gain and loss, the
/// register's tax basis total, the missing line, and the notes with
/// their citations.
#[test]
fn pdf_shows_tax_basis() {
let r = sample();
let t = all_text(&r, &meta());
for s in [
"Federal tax basis",
"TAX BASIS RULE",
"CONTRIBUTOR'S BASIS",
"BUILT-IN GAIN (LOSS)",
"Carryover",
"Personal-use conversion",
"900.00", // MacBook: contributor's basis = tax basis
"399.00", // its built-in gain
"1,917.21", // camera: contributor's basis
"1,650.00", // its tax basis: the fair market value
"(267.21)", // its built-in loss
"Not recorded",
"Total tax basis, assets held",
"5,050.00", // 2,500.00 + 900.00 + 1,650.00; the desk has none
"Tax basis not recorded for 1 asset",
"Notes",
"Book value is the fair market value at contribution",
"IRC §1012(a)",
"IRC §723",
"Treas. Reg. §1.168(i)-4(b)",
"IRC §704(c)(1)(C)",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
let l = layout(&r);
assert_eq!(l.tax_total, r.total_tax_basis);
assert_eq!(l.tax_total, cents(505000));
assert_eq!(
l.missing.as_deref(),
Some("Tax basis not recorded for 1 asset")
);
assert_eq!(l.notes, notes());
// Each line carries its row's tax fields unchanged.
for (line, row) in l.lines.iter().zip(&r.rows) {
assert_eq!(line.tax_rule, row.tax_rule);
assert_eq!(line.contributor_basis, row.contributor_basis);
assert_eq!(line.tax_basis, row.tax_basis);
assert_eq!(line.built_in_gain, row.built_in_gain);
}
}
/// INVARIANT 8: no missing line when every asset held has a tax basis
/// (a disposed one without doesn't count), and the plural reads right.
#[test]
fn missing_line_only_when_missing() {
let mut gone = row(1, "WL-0001", "Old monitor", 40000, 0);
gone.book_value = Decimal::ZERO;
gone.disposed = Some((d(2026, 9, 30), cents(4500)));
let r = register(vec![bought(row(2, "WL-0002", "Desk", 50000, 0)), gone]);
assert_eq!(r.missing_tax_basis, 0);
assert_eq!(layout(&r).missing, None);
assert!(!all_text(&r, &meta()).contains("Tax basis not recorded"));
assert_eq!(missing_line(0), None);
assert_eq!(
missing_line(2).as_deref(),
Some("Tax basis not recorded for 2 assets")
);
}
/// INVARIANT 7: the rules in words, short and with their citations.
#[test]
fn rule_words() {
use TaxBasisRule::*;
assert_eq!(
[Cost, Carryover, PersonalUseConversion].map(rule_label),
["Cost", "Carryover", "Personal-use conversion"]
);
assert_eq!(
[Cost, Carryover, PersonalUseConversion].map(rule_cited),
[
"Cost",
"Carryover (IRC §723)",
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
]
);
} }
/// INVARIANT 4: an empty register renders a "No fixed assets" line, its /// INVARIANT 4: an empty register renders a "No fixed assets" line, its
+106 -21
View File
@@ -3011,7 +3011,10 @@ fn tax_summary_optional_sections() {
/// A register as ledger_core::fixed_asset_register would return it: two /// A register as ledger_core::fixed_asset_register would return it: two
/// assets held, one disposed (shown, in no total), and books that differ /// assets held, one disposed (shown, in no total), and books that differ
/// from the register on both counts. /// from the register on both counts. Tax basis: the laptop a member
/// converted from personal use (worth less than their basis: tax basis is
/// its value, a built-in loss), the desk with none recorded, the monitor
/// a carryover with a built-in gain (disposed, in no total).
fn asset_register_fixture() -> ledger_core::AssetRegister { fn asset_register_fixture() -> ledger_core::AssetRegister {
use ledger_core::AssetRegisterRow; use ledger_core::AssetRegisterRow;
let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| { let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| {
@@ -3027,19 +3030,35 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
accumulated: cents(acc), accumulated: cents(acc),
book_value: cents(cost - acc), book_value: cents(cost - acc),
disposed, disposed,
tax_rule: None,
contributor_basis: None,
tax_basis: None,
built_in_gain: None,
} }
}; };
let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, tax: Decimal| {
r.tax_rule = Some(rule);
r.contributor_basis = Some(basis);
r.tax_basis = Some(tax);
r.built_in_gain = Some(r.cost - basis);
r
};
ledger_core::AssetRegister { ledger_core::AssetRegister {
as_of: d(2026, 10, 15), as_of: d(2026, 10, 15),
rows: vec![ rows: vec![
row( taxed(
1, row(
Some("WL-0001"), 1,
"Framework Laptop 13", Some("WL-0001"),
Some("FRAN123"), "Framework Laptop 13",
250000, Some("FRAN123"),
52778, 250000,
None, 52778,
None,
),
ledger_core::TaxBasisRule::PersonalUseConversion,
cents(280000),
cents(250000),
), ),
row(2, None, "Desk", None, 120000, 25000, None), row(2, None, "Desk", None, 120000, 25000, None),
{ {
@@ -3053,12 +3072,19 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
Some((d(2026, 9, 30), cents(4500))), Some((d(2026, 9, 30), cents(4500))),
); );
r.book_value = Decimal::ZERO; r.book_value = Decimal::ZERO;
r taxed(
r,
ledger_core::TaxBasisRule::Carryover,
cents(30000),
cents(30000),
)
}, },
], ],
total_cost: cents(370000), total_cost: cents(370000),
total_accumulated: cents(77778), total_accumulated: cents(77778),
total_book_value: cents(292222), total_book_value: cents(292222),
total_tax_basis: cents(250000),
missing_tax_basis: 1,
ledger_cost: cents(400000), ledger_cost: cents(400000),
ledger_accumulated: cents(70000), ledger_accumulated: cents(70000),
} }
@@ -3090,7 +3116,7 @@ fn asset_register_xlsx_cells() {
assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC"); assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC");
assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register"); assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register");
assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026"); assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026");
let heads: Vec<String> = (0..10).map(|i| text_cell(&c, (5, i))).collect(); let heads: Vec<String> = (0..14).map(|i| text_cell(&c, (5, i))).collect();
assert_eq!( assert_eq!(
heads, heads,
[ [
@@ -3103,6 +3129,10 @@ fn asset_register_xlsx_cells() {
"LIFE (MONTHS)", "LIFE (MONTHS)",
"ACCUMULATED DEPRECIATION", "ACCUMULATED DEPRECIATION",
"BOOK VALUE", "BOOK VALUE",
"TAX BASIS RULE",
"CONTRIBUTOR'S BASIS",
"TAX BASIS",
"BUILT-IN GAIN (LOSS)",
"STATUS" "STATUS"
] ]
); );
@@ -3115,31 +3145,78 @@ fn asset_register_xlsx_cells() {
assert_eq!(number_cell(&c, (6, 6)), dec("36")); assert_eq!(number_cell(&c, (6, 6)), dec("36"));
assert_eq!(number_cell(&c, (6, 7)), dec("527.78")); assert_eq!(number_cell(&c, (6, 7)), dec("527.78"));
assert_eq!(number_cell(&c, (6, 8)), dec("1972.22")); assert_eq!(number_cell(&c, (6, 8)), dec("1972.22"));
assert_eq!(text_cell(&c, (6, 9)), "Active"); // Tax basis (INVARIANT 7): rule in words with its citation, the
// contributor's basis, tax basis, built-in loss as a negative.
assert_eq!(
text_cell(&c, (6, 9)),
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
);
assert_eq!(number_cell(&c, (6, 10)), dec("2800.00"));
assert_eq!(number_cell(&c, (6, 11)), dec("2500.00"));
assert_eq!(number_cell(&c, (6, 12)), dec("-300.00"));
assert_eq!(text_cell(&c, (6, 13)), "Active");
// The desk: no tag, no serial, no tax basis recorded.
assert_eq!(text_cell(&c, (7, 1)), "Desk"); assert_eq!(text_cell(&c, (7, 1)), "Desk");
assert_eq!(number_cell(&c, (7, 4)), dec("1200.00"));
assert_eq!(number_cell(&c, (7, 7)), dec("250.00"));
assert_eq!(number_cell(&c, (7, 8)), dec("950.00"));
assert_eq!(text_cell(&c, (7, 9)), "Not recorded");
for col in 10..=12 {
assert!(
!c.contains_key(&(7, col)),
"desk has no tax amount in {col}"
);
}
assert_eq!(text_cell(&c, (7, 13)), "Active");
assert_eq!(text_cell(&c, (8, 0)), "WL-0003"); assert_eq!(text_cell(&c, (8, 0)), "WL-0003");
assert_eq!(text_cell(&c, (8, 9)), "Disposed 2026-09-30, proceeds 45.00"); assert_eq!(text_cell(&c, (8, 9)), "Carryover (IRC §723)");
assert_eq!(number_cell(&c, (8, 10)), dec("300.00"));
assert_eq!(number_cell(&c, (8, 11)), dec("300.00"));
assert_eq!(number_cell(&c, (8, 12)), dec("100.00"));
assert_eq!(
text_cell(&c, (8, 13)),
"Disposed 2026-09-30, proceeds 45.00"
);
assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO); assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO);
// Totals: the register's (held assets only), not a re-sum of the rows. // Totals: the register's (held assets only), not a re-sum of the rows.
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held"); assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
assert_eq!(number_cell(&c, (9, 4)), dec("3700.00")); assert_eq!(number_cell(&c, (9, 4)), dec("3700.00"));
assert_eq!(number_cell(&c, (9, 7)), dec("777.78")); assert_eq!(number_cell(&c, (9, 7)), dec("777.78"));
assert_eq!(number_cell(&c, (9, 8)), dec("2922.22")); assert_eq!(number_cell(&c, (9, 8)), dec("2922.22"));
assert!(!c.contains_key(&(9, 5)), "no salvage total"); assert_eq!(number_cell(&c, (9, 11)), dec("2500.00"));
for col in [5, 10, 12] {
assert!(!c.contains_key(&(9, col)), "no total in {col}");
}
// INVARIANT 8: the missing tax basis line.
assert_eq!(text_cell(&c, (10, 0)), "Tax basis not recorded for 1 asset");
// Reconciliation. // Reconciliation.
assert_eq!(text_cell(&c, (11, 0)), "Reconciliation to the books"); assert_eq!(text_cell(&c, (12, 0)), "Reconciliation to the books");
let recon = [ let recon = [
(12, "Cost per register", 4, "3700.00"), (13, "Cost per register", 4, "3700.00"),
(13, "Cost per books", 4, "4000.00"), (14, "Cost per books", 4, "4000.00"),
(14, "Difference (unregistered cost)", 4, "300.00"), (15, "Difference (unregistered cost)", 4, "300.00"),
(16, "Accumulated depreciation per schedule", 7, "777.78"), (17, "Accumulated depreciation per schedule", 7, "777.78"),
(17, "Accumulated depreciation per books", 7, "700.00"), (18, "Accumulated depreciation per books", 7, "700.00"),
(18, "Difference (not yet posted)", 7, "77.78"), (19, "Difference (not yet posted)", 7, "77.78"),
]; ];
for (row, label, col, amt) in recon { for (row, label, col, amt) in recon {
assert_eq!(text_cell(&c, (row, 0)), label); assert_eq!(text_cell(&c, (row, 0)), label);
assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}"); assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}");
} }
// INVARIANT 9: the notes, numbered as the PDF marks them.
assert_eq!(text_cell(&c, (21, 0)), "Notes");
let notes = ledger_import::asset_register::notes();
for (i, n) in notes.iter().enumerate() {
let want = if n.mark.is_empty() {
n.text.clone()
} else {
format!("{}. {}", n.mark, n.text)
};
assert_eq!(text_cell(&c, (22 + i as u32, 0)), want);
}
assert!(
text_cell(&c, (22, 0)).starts_with("Book value is the fair market value at contribution")
);
let footer = c let footer = c
.values() .values()
.any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42"))); .any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42")));
@@ -3156,10 +3233,18 @@ fn asset_register_xlsx_empty() {
total_cost: Decimal::ZERO, total_cost: Decimal::ZERO,
total_accumulated: Decimal::ZERO, total_accumulated: Decimal::ZERO,
total_book_value: Decimal::ZERO, total_book_value: Decimal::ZERO,
total_tax_basis: Decimal::ZERO,
missing_tax_basis: 0,
ledger_cost: cents(99900), ledger_cost: cents(99900),
ledger_accumulated: Decimal::ZERO, ledger_accumulated: Decimal::ZERO,
}; };
let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap()); let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap());
assert_eq!(number_cell(&c, (7, 11)), Decimal::ZERO);
assert!(
!c.values()
.any(|v| matches!(v, Cell::Text(t) if t.starts_with("Tax basis not recorded"))),
"nothing missing"
);
assert_eq!(text_cell(&c, (6, 0)), "No fixed assets"); assert_eq!(text_cell(&c, (6, 0)), "No fixed assets");
assert_eq!(text_cell(&c, (7, 0)), "Total, assets held"); assert_eq!(text_cell(&c, (7, 0)), "Total, assets held");
assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO); assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO);
+76 -5
View File
@@ -54,7 +54,7 @@ use rust_decimal::Decimal;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// The agent operating contract, delivered via initialize. /// The agent operating contract, delivered via initialize.
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or Line truncated const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled Line truncated
/// Max receipt/document upload: 25 MB. /// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -467,6 +467,28 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
out out
} }
/// A tax basis record from a tool's rule name and contributor basis.
fn parse_tax_basis(rule: &str, basis: Option<&str>) -> Result<ledger_core::TaxBasis, String> {
let rule = ledger_core::TaxBasisRule::parse(rule).ok_or_else(|| {
format!("tax basis rule {rule:?}: expected cost, carryover, or personal_use_conversion")
})?;
Ok(ledger_core::TaxBasis {
rule,
contributor_basis: basis.map(parse_amount).transpose()?,
})
}
/// An asset's tax basis for a tool's output.
fn tax_out(a: &ledger_core::Asset) -> Option<TaxBasisOut> {
let t = a.tax?;
Some(TaxBasisOut {
rule: t.rule.key().to_string(),
contributor_basis: t.contributor_basis.map(money),
tax_basis: a.tax_basis().map(money),
built_in_gain: a.built_in_gain().map(money),
})
}
/// An asset named by id or by tag (exactly one). /// An asset named by id or by tag (exactly one).
fn resolve_asset(store: &Store, id: Option<u64>, tag: Option<&str>) -> Result<u64, String> { fn resolve_asset(store: &Store, id: Option<u64>, tag: Option<&str>) -> Result<u64, String> {
match (id, tag) { match (id, tag) {
@@ -2639,6 +2661,25 @@ impl BooksServer {
})) }))
} }
#[tool(
name = "books.set_asset_tax_basis",
description = "Record a fixed asset's federal tax basis beside its book value: rule cost (the company bought it: basis is its cost, IRC §1012), carryover (a member contributed business property: the member's adjusted basis, IRC §723), or personal_use_conversion (a member contributed personal-use property: the lesser of fair market value, which is the book cost, and the member's adjusted basis, Treas. Reg. §1.168(i)-4(b)). Contributions take the member's adjusted basis (what they paid, less any depreciation they took). Reports the tax basis and the built-in gain or loss (IRC §704(c)). Journaled; book figures don't change. Every asset should have one: the register and the CPA package show it."
)]
async fn set_asset_tax_basis(
&self,
params: Parameters<SetAssetTaxBasisParams>,
) -> Result<Json<TaxBasisOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let id = resolve_asset_ref(&store, &p.asset)?;
let tax = parse_tax_basis(&p.rule, p.contributor_basis.as_deref())?;
store
.set_asset_tax_basis(id, tax)
.map_err(|e| e.to_string())?;
let a = store.ledger().get_asset(id).expect("resolved");
Ok(Json(tax_out(a).expect("just set")))
}
#[tool( #[tool(
name = "books.asset_labels", name = "books.asset_labels",
description = "A printable sheet of asset labels (PDF, base64): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print at 100% scale.", description = "A printable sheet of asset labels (PDF, base64): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print at 100% scale.",
@@ -3211,6 +3252,19 @@ impl BooksServer {
)); ));
} }
} }
// Checked before registering, so a bad tax basis registers nothing
// (found by the spec-blind MCP suite, 2026-10-15).
let tax = match &p.tax_basis_rule {
Some(rule) => {
let tax = parse_tax_basis(rule, p.contributor_basis.as_deref())?;
tax.check().map_err(|why| format!("bad tax basis: {why}"))?;
Some(tax)
}
None if p.contributor_basis.is_some() => {
return Err("contributor_basis needs tax_basis_rule".into());
}
None => None,
};
let asset_id = store let asset_id = store
.register_asset(NewAsset { .register_asset(NewAsset {
description: p.description, description: p.description,
@@ -3239,6 +3293,11 @@ impl BooksServer {
.set_asset_acquisition(asset_id, entry) .set_asset_acquisition(asset_id, entry)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
if let Some(tax) = tax {
store
.set_asset_tax_basis(asset_id, tax)
.map_err(|e| e.to_string())?;
}
Ok(Json(RegisterAssetOut { asset_id, tag })) Ok(Json(RegisterAssetOut { asset_id, tag }))
} }
@@ -3304,6 +3363,7 @@ impl BooksServer {
tag: a.tag.clone(), tag: a.tag.clone(),
serial: a.serial.clone(), serial: a.serial.clone(),
acquisition_entry: a.acquired_by, acquisition_entry: a.acquired_by,
tax: tax_out(a),
description: a.description.clone(), description: a.description.clone(),
purchase_date: a.purchase_date.to_string(), purchase_date: a.purchase_date.to_string(),
cost: money(a.cost.amount), cost: money(a.cost.amount),
@@ -4548,7 +4608,13 @@ impl BooksServer {
"book_value": money(a.book_value), "book_value": money(a.book_value),
"status": status(a), "status": status(a),
"proceeds": a.disposed.map(|(_, p)| money(p)), "proceeds": a.disposed.map(|(_, p)| money(p)),
"tax_basis_rule": a.tax_rule.map(|r| r.key()),
"contributor_basis": a.contributor_basis.map(money),
"tax_basis": a.tax_basis.map(money),
"built_in_gain": a.built_in_gain.map(money),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
"total_tax_basis": money(r.total_tax_basis),
"missing_tax_basis": r.missing_tax_basis,
"total_cost": money(r.total_cost), "total_cost": money(r.total_cost),
"total_accumulated": money(r.total_accumulated), "total_accumulated": money(r.total_accumulated),
"total_book_value": money(r.total_book_value), "total_book_value": money(r.total_book_value),
@@ -4560,11 +4626,11 @@ impl BooksServer {
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
} }
let mut csv = String::from( let mut csv = String::from(
"tag,description,serial,in_service,cost,salvage,useful_life_months,accumulated,book_value,status,proceeds\n", "tag,description,serial,in_service,cost,salvage,useful_life_months,accumulated,book_value,status,proceeds,tax_basis_rule,contributor_basis,tax_basis,built_in_gain\n",
); );
for a in &r.rows { for a in &r.rows {
csv.push_str(&format!( csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{}\n", "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
csv_escape(a.tag.as_deref().unwrap_or("")), csv_escape(a.tag.as_deref().unwrap_or("")),
csv_escape(&a.description), csv_escape(&a.description),
csv_escape(a.serial.as_deref().unwrap_or("")), csv_escape(a.serial.as_deref().unwrap_or("")),
@@ -4576,13 +4642,18 @@ impl BooksServer {
money(a.book_value), money(a.book_value),
status(a), status(a),
a.disposed.map(|(_, p)| money(p)).unwrap_or_default(), a.disposed.map(|(_, p)| money(p)).unwrap_or_default(),
a.tax_rule.map(|r| r.key()).unwrap_or_default(),
a.contributor_basis.map(money).unwrap_or_default(),
a.tax_basis.map(money).unwrap_or_default(),
a.built_in_gain.map(money).unwrap_or_default(),
)); ));
} }
csv.push_str(&format!( csv.push_str(&format!(
"TOTAL,,,,{},,,{},{},,", "TOTAL,,,,{},,,{},{},,,,,{},",
money(r.total_cost), money(r.total_cost),
money(r.total_accumulated), money(r.total_accumulated),
money(r.total_book_value) money(r.total_book_value),
money(r.total_tax_basis)
)); ));
Ok(csv) Ok(csv)
} }
+40
View File
@@ -1515,6 +1515,44 @@ pub struct RegisterAssetParams {
/// The posted entry that put it on the books (its purchase, or a /// The posted entry that put it on the books (its purchase, or a
/// member's contribution of it); it must debit the cost account. /// member's contribution of it); it must debit the cost account.
pub acquisition_entry: Option<u64>, pub acquisition_entry: Option<u64>,
/// The federal tax basis rule (see books.set_asset_tax_basis): cost,
/// carryover, or personal_use_conversion. Omit to record it later.
pub tax_basis_rule: Option<String>,
/// The contributing member's adjusted basis, for carryover and
/// personal_use_conversion.
pub contributor_basis: Option<String>,
}
/// Parameters for `books.set_asset_tax_basis`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetAssetTaxBasisParams {
/// The asset: its id or tag.
pub asset: String,
/// "cost" (the company bought it: basis is its cost), "carryover" (a
/// member contributed business property: the member's adjusted basis,
/// IRC §723), or "personal_use_conversion" (a member contributed
/// personal-use property: the lesser of fair market value and the
/// member's adjusted basis, Treas. Reg. §1.168(i)-4(b)).
pub rule: String,
/// The contributing member's adjusted basis at contribution, decimal
/// string (what they paid, less any depreciation they took). Required
/// for carryover and personal_use_conversion; omit for cost.
pub contributor_basis: Option<String>,
}
/// An asset's federal tax basis, as tools report it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TaxBasisOut {
/// The rule: cost, carryover, or personal_use_conversion.
pub rule: String,
/// The contributing member's adjusted basis, decimal string, if a
/// contribution.
pub contributor_basis: Option<String>,
/// The depreciable federal tax basis, decimal string.
pub tax_basis: Option<String>,
/// Built-in gain (+) or loss (−) at contribution (fair market value less
/// the contributor's basis), IRC §704(c), decimal string.
pub built_in_gain: Option<String>,
} }
/// Result of `books.register_asset`. /// Result of `books.register_asset`.
@@ -1639,6 +1677,8 @@ pub struct AssetSummary {
pub serial: Option<String>, pub serial: Option<String>,
/// The entry that acquired it, if linked. /// The entry that acquired it, if linked.
pub acquisition_entry: Option<u64>, pub acquisition_entry: Option<u64>,
/// Its federal tax basis, if recorded.
pub tax: Option<TaxBasisOut>,
/// Asset description. /// Asset description.
pub description: String, pub description: String,
/// Purchase date, YYYY-MM-DD. /// Purchase date, YYYY-MM-DD.
+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 72, "expected 72 tools, got {}", tools.len()); assert_eq!(tools.len(), 73, "expected 73 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true); assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!( assert!(
+328
View File
@@ -889,3 +889,331 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
assert!(text.contains("EARLY-1")); assert!(text.contains("EARLY-1"));
assert!(!text.contains("LATE-1"), "{text}"); assert!(!text.contains("LATE-1"), "{text}");
} }
// ------------------------------------------------- tax basis (rules.md B-25)
//
// From the `books.set_asset_tax_basis` description and
// `SetAssetTaxBasisParams` / `TaxBasisOut` docs, `RegisterAssetParams`
// (`tax_basis_rule`, `contributor_basis`), `AssetSummary::tax`, and the
// fixed_asset_register export. Cost: the book cost (IRC §1012);
// carryover: the member's adjusted basis (IRC §723);
// personal_use_conversion: the lesser of fair market value (the book
// cost) and the member's basis (Treas. Reg. §1.168(i)-4(b)); built-in
// gain = fair market value less the member's basis (IRC §704(c)).
/// A decimal-string field, compared by value; `None` for null.
fn dec(v: &Value, key: &str) -> Option<String> {
match &v[key] {
Value::Null => None,
Value::String(s) => Some(canon(s)),
other => panic!("{key} is not a decimal string or null: {other}"),
}
}
fn tax_basis_lines(m: &Mcp) -> usize {
m.journal_types()
.iter()
.filter(|t| *t == "asset.tax_basis_set")
.count()
}
/// books.set_asset_tax_basis by tag and by id; the output reports rule,
/// contributor basis, tax basis, and built-in gain; each call journals.
#[test]
fn set_asset_tax_basis_by_tag_or_id_reports_the_basis() {
let mut m = books("tax-set");
let (a, t) = m.register_ok(json!({}));
assert_eq!(m.asset(a)["tax"], Value::Null, "not recorded yet");
// By tag (any case): carryover of the member's $900 basis.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t.to_ascii_lowercase(), "rule": "carryover", "contributor_basis": "900.00"}),
);
assert_eq!(r["rule"], "carryover");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("900"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("900"));
assert_eq!(
dec(&r, "built_in_gain").as_deref(),
Some("300"),
"1200 − 900"
);
assert_eq!(tax_basis_lines(&m), 1);
// By id: personal-use conversion, member paid $2,000 for a $1,200 laptop.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": a.to_string(), "rule": "personal_use_conversion", "contributor_basis": "2000.00"}),
);
assert_eq!(r["rule"], "personal_use_conversion");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"), "the lesser");
assert_eq!(
dec(&r, "built_in_gain").as_deref(),
Some("-800"),
"a built-in loss"
);
assert_eq!(tax_basis_lines(&m), 2);
// Boundary: value equals basis.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "personal_use_conversion", "contributor_basis": "1200.00"}),
);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("0"));
// Cost: the book cost, no contributor basis, no built-in gain.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "cost"}),
);
assert_eq!(r["rule"], "cost");
assert_eq!(r["contributor_basis"], Value::Null);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(r["built_in_gain"], Value::Null);
assert_eq!(tax_basis_lines(&m), 4);
// list_assets shows the latest record.
let tax = m.asset(a)["tax"].clone();
assert_eq!(tax["rule"], "cost");
assert_eq!(dec(&tax, "tax_basis").as_deref(), Some("1200"));
}
/// Refusals: unknown asset, bad rule, cost with a basis, a contribution
/// without one, negative, finer than cents, not a decimal string. Each
/// leaves the journal byte-identical (the harness checks) and the record.
#[test]
fn set_asset_tax_basis_refusals_change_nothing() {
let mut m = books("tax-refuse");
let (a, t) = m.register_ok(json!({}));
m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover", "contributor_basis": "500.00"}),
);
let before = m.asset(a)["tax"].clone();
for args in [
json!({"asset": "NOPE-1", "rule": "cost"}),
json!({"asset": "999", "rule": "cost"}),
json!({"asset": t, "rule": "fmv"}),
json!({"asset": t, "rule": "Cost"}),
json!({"asset": t, "rule": ""}),
json!({"asset": t, "rule": "cost", "contributor_basis": "100.00"}),
json!({"asset": t, "rule": "carryover"}),
json!({"asset": t, "rule": "personal_use_conversion"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "-1.00"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "1.001"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "lots"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "1,000.00"}),
] {
m.refuse("books.set_asset_tax_basis", args);
}
assert_eq!(m.asset(a)["tax"], before);
assert_eq!(tax_basis_lines(&m), 1);
}
/// register_asset with tax_basis_rule / contributor_basis records it at
/// registration; without them, list_assets shows tax null.
#[test]
fn register_asset_records_a_tax_basis() {
let mut m = books("tax-register");
let (a, _) = m.register_ok(json!({"tax_basis_rule": "cost"}));
let (b, _) = m.register_ok(json!({"tax_basis_rule": "personal_use_conversion",
"contributor_basis": "1500.00"}));
let (c, _) = m.register_ok(json!({"tax_basis_rule": "carryover",
"contributor_basis": "400.00"}));
let (n, _) = m.register_ok(json!({}));
let ta = m.asset(a)["tax"].clone();
assert_eq!(ta["rule"], "cost");
assert_eq!(ta["contributor_basis"], Value::Null);
assert_eq!(dec(&ta, "tax_basis").as_deref(), Some("1200"));
assert_eq!(ta["built_in_gain"], Value::Null);
let tb = m.asset(b)["tax"].clone();
assert_eq!(tb["rule"], "personal_use_conversion");
assert_eq!(dec(&tb, "contributor_basis").as_deref(), Some("1500"));
assert_eq!(dec(&tb, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&tb, "built_in_gain").as_deref(), Some("-300"));
let tc = m.asset(c)["tax"].clone();
assert_eq!(tc["rule"], "carryover");
assert_eq!(dec(&tc, "tax_basis").as_deref(), Some("400"));
assert_eq!(dec(&tc, "built_in_gain").as_deref(), Some("800"));
assert_eq!(m.asset(n)["tax"], Value::Null);
}
/// contributor_basis without tax_basis_rule, or a bad rule, is refused
/// and registers nothing.
#[test]
fn register_asset_refuses_a_bad_tax_basis() {
let mut m = books("tax-register-bad");
m.register_ok(json!({}));
for extra in [
json!({"contributor_basis": "500.00"}),
json!({"tax_basis_rule": "market_value"}),
json!({"tax_basis_rule": "carryover", "contributor_basis": "five"}),
] {
let out = m.register(extra.clone());
assert!(out.refused(), "{extra}: {out:?}");
}
assert_eq!(m.assets().len(), 1, "nothing registered");
}
/// A record the engine refuses (Ledger::set_asset_tax_basis INVARIANTS
/// 1–2) given at registration is a refused call, so (server invariant 2)
/// it registers nothing either.
#[test]
fn register_asset_with_a_basis_the_engine_refuses_registers_nothing() {
let mut m = books("tax-register-engine");
for extra in [
json!({"tax_basis_rule": "carryover"}),
json!({"tax_basis_rule": "personal_use_conversion"}),
json!({"tax_basis_rule": "cost", "contributor_basis": "100.00"}),
json!({"tax_basis_rule": "carryover", "contributor_basis": "-1.00"}),
json!({"tax_basis_rule": "carryover", "contributor_basis": "1.001"}),
] {
let out = m.register(extra.clone());
assert!(out.refused(), "{extra}: {out:?}");
}
assert!(m.assets().is_empty(), "nothing registered");
}
/// Every object anywhere in `v` that has `key`.
fn objects_with<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) {
match v {
Value::Object(m) => {
if m.contains_key(key) {
out.push(v);
}
m.values().for_each(|c| objects_with(c, key, out));
}
Value::Array(a) => a.iter().for_each(|c| objects_with(c, key, out)),
_ => {}
}
}
/// The fixed_asset_register export carries each asset's tax basis rule,
/// contributor basis, tax basis, and built-in gain; JSON also the total
/// tax basis of held assets that have one and how many held assets have
/// none. A disposed asset is out of the totals.
#[test]
fn export_fixed_asset_register_carries_tax_basis() {
let mut m = books("tax-export");
m.post(
"2026-01-01",
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
);
m.register_ok(json!({"tag": "BOUGHT-1", "tax_basis_rule": "cost"}));
m.register_ok(json!({"tag": "CARRY-1", "tax_basis_rule": "carryover",
"contributor_basis": "650.25"}));
m.register_ok(
json!({"tag": "CONV-1", "tax_basis_rule": "personal_use_conversion",
"contributor_basis": "2000.00"}),
);
m.register_ok(json!({"tag": "NONE-1"}));
let (_, sold) = m.register_ok(json!({"tag": "SOLD-1", "tax_basis_rule": "cost"}));
let doc = m.file_bill_of_sale();
m.ok(
"books.dispose_asset",
json!({"tag": sold, "date": "2026-03-31", "proceeds": "100.00",
"proceeds_account": "1000", "gain_loss_account": "7100",
"confirmed": true, "receipt_attachment_ids": [doc]}),
);
let export = |m: &mut Mcp, format: &str| -> String {
m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}),
)["content"]
.as_str()
.unwrap()
.to_string()
};
// JSON.
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
let mut rows = Vec::new();
objects_with(&j, "tax_basis_rule", &mut rows);
assert_eq!(rows.len(), 5, "{j}");
let row = |tag: &str| -> Value {
(*rows
.iter()
.find(|r| r["tag"] == tag)
.unwrap_or_else(|| panic!("{tag} row")))
.clone()
};
let r = row("BOUGHT-1");
assert_eq!(r["tax_basis_rule"], "cost");
assert_eq!(r["contributor_basis"], Value::Null);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(r["built_in_gain"], Value::Null);
let r = row("CARRY-1");
assert_eq!(r["tax_basis_rule"], "carryover");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("650.25"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("650.25"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("549.75"));
let r = row("CONV-1");
assert_eq!(r["tax_basis_rule"], "personal_use_conversion");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("-800"));
let r = row("NONE-1");
assert_eq!(r["tax_basis_rule"], Value::Null);
assert_eq!(r["tax_basis"], Value::Null);
let r = row("SOLD-1");
assert_eq!(r["tax_basis_rule"], "cost");
// Held with a basis: 1200 + 650.25 + 1200; SOLD-1 out, NONE-1 missing.
assert_eq!(the_value(&j, "total_tax_basis"), "3050.25");
let mut missing = Vec::new();
find_key(&j, "missing_tax_basis", &mut missing);
assert_eq!(missing, vec![&json!(1)]);
// CSV: the four tax columns, by header.
let csv = export(&mut m, "csv");
let mut lines = csv.lines();
let header: Vec<String> = lines
.next()
.unwrap()
.split(',')
.map(|h| h.trim().to_ascii_lowercase())
.collect();
let col = |name: &str| {
header
.iter()
.position(|h| h == name)
.unwrap_or_else(|| panic!("{name} column in {header:?}"))
};
let (c_tag, c_rule, c_basis, c_tax, c_gain) = (
col("tag"),
col("tax_basis_rule"),
col("contributor_basis"),
col("tax_basis"),
col("built_in_gain"),
);
let body: Vec<Vec<String>> = lines
.map(|l| l.split(',').map(str::to_string).collect())
.collect();
let csv_row = |tag: &str| {
body.iter()
.find(|r| r.get(c_tag).map(String::as_str) == Some(tag))
.unwrap_or_else(|| panic!("{tag} in {csv}"))
.clone()
};
let r = csv_row("CARRY-1");
assert_eq!(r[c_rule], "carryover");
assert_eq!(canon(&r[c_basis]), "650.25");
assert_eq!(canon(&r[c_tax]), "650.25");
assert_eq!(canon(&r[c_gain]), "549.75");
let r = csv_row("CONV-1");
assert_eq!(r[c_rule], "personal_use_conversion");
assert_eq!(canon(&r[c_tax]), "1200");
assert_eq!(canon(&r[c_gain]), "-800");
let r = csv_row("BOUGHT-1");
assert_eq!(r[c_rule], "cost");
assert_eq!(r[c_basis], "");
assert_eq!(canon(&r[c_tax]), "1200");
assert_eq!(r[c_gain], "");
let r = csv_row("NONE-1");
assert_eq!((r[c_rule].as_str(), r[c_tax].as_str()), ("", ""));
// The TOTAL row's tax basis column is the total tax basis.
let total = csv_row("TOTAL");
assert_eq!(canon(&total[c_tax]), "3050.25");
}
+4
View File
@@ -792,6 +792,10 @@ fn happy_path(name: &str) -> Mcp {
"books.label_asset", "books.label_asset",
json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}), json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}),
); );
m.ok(
"books.set_asset_tax_basis",
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}),
);
m.ok("books.asset_labels", json!({})); m.ok("books.asset_labels", json!({}));
m.ok( m.ok(
"books.asset_labels", "books.asset_labels",
+8 -1
View File
@@ -5,7 +5,7 @@
//! is the current version. //! is the current version.
/// Current schema version. /// Current schema version.
pub const SCHEMA_VERSION: u32 = 9; pub const SCHEMA_VERSION: u32 = 10;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[ pub const MIGRATIONS: &[&str] = &[
@@ -220,5 +220,12 @@ CREATE TABLE IF NOT EXISTS asset_tags_log(
asset_id INTEGER NOT NULL, asset_id INTEGER NOT NULL,
tag TEXT NOT NULL tag TEXT NOT NULL
); );
"#,
r#"
-- v10: each asset's federal tax basis: the rule (cost, carryover,
-- personal_use_conversion) and the contributing member's adjusted basis
-- (TEXT, exact decimal, never REAL).
ALTER TABLE assets ADD COLUMN tax_rule TEXT;
ALTER TABLE assets ADD COLUMN contributor_basis TEXT;
"#, "#,
]; ];
+70
View File
@@ -725,6 +725,27 @@ impl Store {
Ok(()) Ok(())
} }
/// Record an asset's federal tax basis (see
/// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`.
pub fn set_asset_tax_basis(
&mut self,
id: AssetId,
tax: ledger_core::TaxBasis,
) -> Result<(), StoreError> {
self.ledger.check_asset_tax_basis(id, tax)?;
let line = self.append_journal(
"asset.tax_basis_set",
json!({
"asset_id": id,
"rule": tax.rule.key(),
"contributor_basis": tax.contributor_basis.map(|b| b.to_string()),
}),
)?;
self.ledger.set_asset_tax_basis(id, tax)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Change an asset's description (see `Ledger::set_asset_description`). /// Change an asset's description (see `Ledger::set_asset_description`).
/// Journaled as `asset.described`, with the description it had. /// Journaled as `asset.described`, with the description it had.
pub fn set_asset_description( pub fn set_asset_description(
@@ -2897,6 +2918,21 @@ impl Store {
let (id, serial) = row?; let (id, serial) = row?;
ledger.set_asset_serial(id as u64, Some(&serial))?; ledger.set_asset_serial(id as u64, Some(&serial))?;
} }
let mut xstmt = self.conn.prepare(
"SELECT id, tax_rule, contributor_basis FROM assets WHERE tax_rule IS NOT NULL ORDER BY id",
)?;
let taxes = xstmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
))
})?;
for row in taxes {
let (id, rule, basis) = row?;
let tax = tax_basis_from_journal(&json!({"rule": rule, "contributor_basis": basis}))?;
ledger.set_asset_tax_basis(id as u64, tax)?;
}
let mut qstmt = self.conn.prepare( let mut qstmt = self.conn.prepare(
"SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id", "SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id",
)?; )?;
@@ -3452,6 +3488,10 @@ pub(crate) fn replay_line_into_ledger(
"asset.tagged" => { "asset.tagged" => {
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?;
} }
"asset.tax_basis_set" => {
let tax = tax_basis_from_journal(p)?;
ledger.set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?;
}
"asset.described" => { "asset.described" => {
ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?; ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?;
} }
@@ -3680,6 +3720,26 @@ fn apply_document_payment(
/// The close target recorded in a `period.closed` line. Absent or /// The close target recorded in a `period.closed` line. Absent or
/// `"retained_earnings"`: Retained Earnings with no sweeps (every journal /// `"retained_earnings"`: Retained Earnings with no sweeps (every journal
/// written before targets existed). /// written before targets existed).
/// An `asset.tax_basis_set` payload's rule and contributor basis, strictly:
/// an unknown rule or a basis that isn't a decimal string is corrupt.
fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError> {
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.tax_basis_set {what}"));
let rule =
ledger_core::TaxBasisRule::parse(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?;
let contributor_basis = match p.get("contributor_basis") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(|s| s.parse::<Decimal>().ok())
.ok_or_else(|| corrupt("contributor_basis"))?,
),
};
Ok(ledger_core::TaxBasis {
rule,
contributor_basis,
})
}
fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> { fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}")); let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> { let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
@@ -3929,6 +3989,16 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
params![id, tag], params![id, tag],
)?; )?;
} }
"asset.tax_basis_set" => {
conn.execute(
"UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?",
params![
get_str(p, "rule")?,
p.get("contributor_basis").and_then(|v| v.as_str()),
get_u64(p, "asset_id")? as i64
],
)?;
}
"asset.described" => { "asset.described" => {
conn.execute( conn.execute(
"UPDATE assets SET description = ? WHERE id = ?", "UPDATE assets SET description = ? WHERE id = ?",
+235 -2
View File
@@ -3,7 +3,9 @@
//! `asset.serial_set` {asset_id, serial | null}), replay on reopen, the v9 //! `asset.serial_set` {asset_id, serial | null}), replay on reopen, the v9
//! SQLite projection agreeing with the journal (`Store::verify`), books //! SQLite projection agreeing with the journal (`Store::verify`), books
//! written before tags existed, refusals journaling nothing, and corrupt //! written before tags existed, refusals journaling nothing, and corrupt
//! `asset.serial_set` lines refused on open. //! `asset.serial_set` lines refused on open; and tax basis (B-25:
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
//! v10 migration, corrupt payloads).
//! //!
//! Expectations come from the doc comments on `Store::set_asset_tag`, //! Expectations come from the doc comments on `Store::set_asset_tag`,
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`, //! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
@@ -349,8 +351,10 @@ fn an_older_asset_registered_line_still_replays() {
} }
/// Downgrade the database to schema v8 (no assets.tag / assets.serial, no /// Downgrade the database to schema v8 (no assets.tag / assets.serial, no
/// asset_tags_log), as an older wanderbooks left it. /// asset_tags_log, and none of v10's tax basis columns), as an older
/// wanderbooks left it.
fn downgrade_db_to_v8(dir: &Path) { fn downgrade_db_to_v8(dir: &Path) {
downgrade_db_to_v9(dir);
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap(); let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
conn.execute_batch( conn.execute_batch(
"ALTER TABLE assets DROP COLUMN tag; "ALTER TABLE assets DROP COLUMN tag;
@@ -524,3 +528,232 @@ fn a_description_change_replays_and_verifies() {
); );
s.verify().unwrap(); s.verify().unwrap();
} }
// ------------------------------------------------- tax basis (rules.md B-25)
//
// Expectations from `Store::set_asset_tax_basis` (journaled as
// `asset.tax_basis_set`), `Ledger::set_asset_tax_basis` INVARIANTS and
// SCENARIOS, and the schema v10 migration comment (tax_rule and
// contributor_basis as TEXT, exact decimal).
fn tax(rule: ledger_core::TaxBasisRule, basis: Option<&str>) -> ledger_core::TaxBasis {
ledger_core::TaxBasis {
rule,
contributor_basis: basis.map(|b| b.parse().unwrap()),
}
}
fn tax_of(s: &Store, id: AssetId) -> Option<ledger_core::TaxBasis> {
s.ledger().get_asset(id).unwrap().tax
}
/// Journaled as `asset.tax_basis_set` {asset_id, rule, contributor_basis}:
/// the basis a decimal string, or null for a purchase.
#[test]
fn tax_basis_journals_asset_tax_basis_set() {
use ledger_core::TaxBasisRule::*;
let (dir, mut s, _, x, y) = fixture("journal-tax");
s.set_asset_tax_basis(x, tax(Cost, None)).unwrap();
s.set_asset_tax_basis(y, tax(PersonalUseConversion, Some("1500.25")))
.unwrap();
s.set_asset_tax_basis(x, tax(Carryover, Some("800.00")))
.unwrap();
let j = journal(&dir);
let set: Vec<&Value> = j
.iter()
.filter(|(t, _)| t == "asset.tax_basis_set")
.map(|(_, p)| p)
.collect();
assert_eq!(set.len(), 3);
assert_eq!(
set[0],
&json!({"asset_id": x, "rule": "cost", "contributor_basis": null})
);
assert_eq!(set[1]["asset_id"], json!(y));
assert_eq!(set[1]["rule"], "personal_use_conversion");
let b = set[1]["contributor_basis"]
.as_str()
.expect("a decimal string");
assert_eq!(
b.parse::<rust_decimal::Decimal>().unwrap(),
"1500.25".parse().unwrap()
);
assert_eq!(set[2]["rule"], "carryover");
let b = set[2]["contributor_basis"]
.as_str()
.expect("a decimal string");
assert_eq!(
b.parse::<rust_decimal::Decimal>().unwrap(),
"800.00".parse().unwrap()
);
// Set again replaces (the journal keeps both).
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("800.00"))));
assert_eq!(
s.ledger().get_asset(y).unwrap().tax_basis(),
Some("1200.00".parse().unwrap()),
"lesser of the 1200.00 book cost and the 1500.25 basis"
);
s.verify().unwrap();
}
/// A refused tax basis (Ledger::set_asset_tax_basis INVARIANT 3) journals
/// nothing and changes nothing, now or on reopen.
#[test]
fn a_refused_tax_basis_journals_nothing() {
use ledger_core::TaxBasisRule::*;
let (dir, mut s, _, x, _) = fixture("refused-tax");
s.set_asset_tax_basis(x, tax(Carryover, Some("100.00")))
.unwrap();
let lines = journal(&dir).len();
let before = s.ledger().clone();
for bad in [
tax(Cost, Some("100.00")),
tax(Carryover, None),
tax(PersonalUseConversion, None),
tax(Carryover, Some("-0.01")),
tax(PersonalUseConversion, Some("10.001")),
] {
assert!(
matches!(
s.set_asset_tax_basis(x, bad),
Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_)))
),
"{bad:?}"
);
}
assert!(matches!(
s.set_asset_tax_basis(x + 99, tax(Cost, None)),
Err(StoreError::Ledger(LedgerError::UnknownAsset(_)))
));
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
assert_eq!(s.ledger(), &before);
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger(), &before);
s.verify().unwrap();
}
/// Tax basis replays on reopen, with the database and rebuilt from the
/// journal alone, and verifies each time.
#[test]
fn tax_basis_replays_on_reopen() {
use ledger_core::TaxBasisRule::*;
let (dir, mut s, a, x, y) = fixture("replay-tax");
let z = laptop(&mut s, &a, "Laptop C");
s.set_asset_tax_basis(x, tax(Carryover, Some("900.00")))
.unwrap();
s.set_asset_tax_basis(x, tax(PersonalUseConversion, Some("2000.00")))
.unwrap();
s.set_asset_tax_basis(y, tax(Cost, None)).unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
assert_eq!(
tax_of(&s, x),
Some(tax(PersonalUseConversion, Some("2000.00")))
);
assert_eq!(tax_of(&s, y), Some(tax(Cost, None)));
assert_eq!(tax_of(&s, z), None);
let ax = s.ledger().get_asset(x).unwrap();
assert_eq!(ax.tax_basis(), Some("1200.00".parse().unwrap()));
assert_eq!(ax.built_in_gain(), Some("-800.00".parse().unwrap()));
drop(s);
}
}
/// Downgrade the database to schema v9 (no assets.tax_rule /
/// assets.contributor_basis), as an older wanderbooks left it.
fn downgrade_db_to_v9(dir: &Path) {
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
conn.execute_batch(
"ALTER TABLE assets DROP COLUMN tax_rule;
ALTER TABLE assets DROP COLUMN contributor_basis;
UPDATE meta SET value = '9' WHERE key = 'schema_version';",
)
.unwrap();
}
/// An existing v9 database migrates on open, verifies, and takes tax bases.
#[test]
fn a_v9_database_migrates_and_verifies() {
use ledger_core::TaxBasisRule::*;
let (dir, mut s, _, x, y) = fixture("v9");
s.set_asset_tag(x, "WL-0001").unwrap();
drop(s);
downgrade_db_to_v9(&dir);
let mut s = Store::open(&dir).expect("a v9 database opens");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001"));
assert_eq!(tax_of(&s, x), None);
s.set_asset_tax_basis(y, tax(Carryover, Some("640.00")))
.unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(tax_of(&s, y), Some(tax(Carryover, Some("640.00"))));
}
/// A v9 database behind a journal with a tax basis line migrates, applies
/// it (Store::open SCENARIO 7), and verifies.
#[test]
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
use ledger_core::TaxBasisRule::*;
let (dir, s, _, x, _) = fixture("v9-behind");
drop(s);
downgrade_db_to_v9(&dir);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": x, "rule": "carryover", "contributor_basis": "300.00"}),
)
.unwrap();
drop(w);
let s = Store::open(&dir).expect("opens and catches up");
s.verify().unwrap();
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00"))));
}
/// An `asset.tax_basis_set` line with an unknown rule, or a contributor
/// basis that isn't a decimal string (or null), is corrupt: refused on
/// open with JournalCorrupt, and on a rebuild from the journal alone.
#[test]
fn an_asset_tax_basis_set_line_with_a_bad_payload_is_corrupt() {
let bads = [
json!({"rule": "fmv", "contributor_basis": null}),
json!({"rule": "Cost", "contributor_basis": null}),
json!({"rule": "carryover", "contributor_basis": 300}),
json!({"rule": "carryover", "contributor_basis": 300.5}),
json!({"rule": "carryover", "contributor_basis": "three hundred"}),
json!({"rule": "personal_use_conversion", "contributor_basis": ""}),
json!({"rule": "personal_use_conversion", "contributor_basis": ["300.00"]}),
];
for bad in bads {
let (dir, s, _, x, _) = fixture("corrupt-tax");
drop(s);
let mut p = bad.clone();
p["asset_id"] = json!(x);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append("asset.tax_basis_set", p).unwrap();
drop(w);
match Store::open(&dir) {
Err(StoreError::JournalCorrupt(_)) => {}
Err(other) => panic!("{bad}: expected JournalCorrupt, got {other:?}"),
Ok(_) => panic!("{bad}: a corrupt asset.tax_basis_set line opened"),
}
let _ = std::fs::remove_file(dir.join(DB_FILE));
match Store::open(&dir) {
Err(StoreError::JournalCorrupt(_)) => {}
Err(other) => panic!("{bad} rebuild: expected JournalCorrupt, got {other:?}"),
Ok(_) => panic!("{bad} rebuild: a corrupt asset.tax_basis_set line opened"),
}
}
}
+229
View File
@@ -0,0 +1,229 @@
# Parties and attachments
Design for two features built together (Stephen, 2026-10-15): a directory
of the parties the company deals with, and attachments that link to any
subject. Status: design; built after asset tax basis.
## Why together
Both answer "what is this connected to?" A W-9 note, a contract, a bank
statement, a photo of a laptop: each is a file about a party, an account, a
reconciliation, an asset, a period. One subject model serves both.
## Parties
A party is anyone the company deals with. Today's vendors and customers
become parties; employees stop being name strings on payroll lines; the
Apple, Google, Anthropic, IRS, and Utah that live only in entry
descriptions get records.
### Model (ledger-core, `parties.rs`)
```text
Party {
id: PartyId, // vendors and customers keep their ids (see Migration)
name: String, // legal or trade name, as on documents
display_name: Option<String>,
roles: BTreeSet<Role>, // vendor, contractor, customer, employee, member,
// payout_platform, tax_agency, financial_institution, landlord
website: Option<String>,
address: Option<String>, // business address; no personal PII beyond it
w9: Option<W9Facts>, // contractor/landlord: the TIN itself is in the vault
w4: Option<W4Facts>, // employee: never the SSN
form_1099: Option<Form1099>, // carried over from Vendor
member: Option<String>, // a member's name in wanderbooks.toml, when role member
notes: Option<String>,
archived: bool, // no longer dealt with; history stays
}
W9Facts { received: NaiveDate, entity_type: String, exempt_payee_code: Option<String>,
fatca_code: Option<String>, tin_last4: String, kept_at: String }
W4Facts { received: NaiveDate, state: String, kept_at: String }
```
Rules (to vendor as B-26 with sources):
- A full TIN or SSN is never in the journal: `tin_last4` is exactly four
digits, and any nine-digit run in any party field is refused (the engine
checks, not the agent). Full TINs live only in the vault (below).
Source: IRS truncated-TIN rules (Treas. Reg. §301.6109-4, to vendor) for
what may appear; DECISION (Stephen, 2026-10-15) for the vault.
- Roles are a set; each role's facts are only allowed with the role
(W-9 facts need contractor or landlord; W-4 facts need employee).
- Names are unique ignoring case and surrounding space among
non-archived parties.
- A 1099 needs a contractor or landlord role; 1099 totals come from the
entries tagged with the party, as today's vendor 1099 report does.
### Links
- Entries: `entry.party_set {entry_id, party_id|null}` generalizes
`entry.vendor_set`; an entry has one counterparty.
- Invoices and bills already name a customer or vendor id: those are
party ids after migration.
- Payroll lines name an employee party id (old runs keep their name
strings; new runs must name a party with the employee role).
### Journal events
`party.added {id, name, roles}`, `party.updated {id, field, value}` (one
field per event: name, display_name, website, address, notes, archived),
`party.role_added {id, role}`, `party.role_removed {id, role}`,
`party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`,
`entry.party_set`. Replayed and projected like the rest; every event gets
viewer journal text (the GUI test enforces it).
### Migration
Old journals replay unchanged. `vendor.added` and `customer.added` keep
their meaning and create parties with the vendor or customer role; the
party id space starts above the highest vendor and customer ids, and a
vendor and a customer with the same name stay two parties until the
human merges them (`books.merge_parties`, journaled, re-pointing links).
`entry.vendor_set` replays as `entry.party_set`. `vendor.1099_set` sets
the party's `form_1099` and the contractor role.
### Tools
`books.add_party`, `books.update_party`, `books.party_roles`,
`books.set_w9`, `books.set_w4`, `books.parties` (list, filter by role),
`books.party` (one: facts, roles, totals this year, documents, links),
`books.merge_parties`, `books.set_entry_party`. `books.add_vendor` and
`books.add_customer` stay as shortcuts.
### Viewer
A Parties page (by role, searchable) and a page per party: facts, roles,
year-to-date paid / owed / invoiced / 1099 amount, every entry, invoice,
bill, payroll run, documents, and history. Members link to their capital
accounts. Global search finds parties. Needs-attention: a contractor
paid without W-9 facts; a 1099 party paid over the threshold.
## The TIN vault
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099
worksheet it hands the CPA is complete, but never in the journal.
- **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in
the hash-chained journal, the SQLite projection, git, an export other
than the 1099 worksheet, or the viewer. Nothing outside the vault holds
more than the last four digits.
- **What:** per party, the full TIN, its type (SSN, EIN, ITIN), and the
W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random
nonce per record; the file format carries a version and the party id as
associated data, so a record can't be moved to another party).
- **Key:** a random 256-bit key in the OS keyring (Secret Service on Linux,
Keychain on macOS), created on first use; never on disk in the books dir.
A recovery copy is the human's job: `just vault-export` writes the key
and records as one passphrase-encrypted file for a password manager.
- **Journal:** `party.w9_set` records that a W-9 was received, its date,
entity type, codes, and last four, plus a hash of the vault record, so
the books prove a W-9 existed without holding it.
- **Purge:** `books.purge_tin {party, reason}` deletes the vault record
(after the retention period, or on request) and journals that it was
purged; the journal's facts stay.
- **Use:** only `books.vendor_1099` with `include_tins: true` (and the CPA
package, when asked) decrypts them. The MCP server never returns a TIN
in any other tool, log, or error. Tests check that no other output
contains a nine-digit run.
- **Books without a vault** (another machine, a restored backup) still
open and verify; the 1099 worksheet then says which TINs are missing.
## Attachments
### Subjects
An attachment links to any number of subjects:
```text
Subject = Entry(id) | Account(id) | Reconciliation(id) | Asset(id) | Party(id)
| Invoice(id) | Bill(id) | Period(id) | PayrollRun(id) | Schedule(id)
| Company
```
`attachment.linked {attachment_id, subject}` and `attachment.unlinked
{attachment_id, subject, reason}` replace the single `entry_id` link (old
`receipt.attached` with an entry and `receipt.linked` replay as links to
that entry). A receipt is an attachment linked to an entry; the receipt
policy counts links to the entry, as now. A file stored once can be
linked to many subjects (no more refiling).
### Kinds
`receipt`, `statement`, `contract`, `formation`, `legal`, `tax`,
`accounting`, `photo`, `warranty`, `import_source`, `other`. A statement
carries `{account_id, period_start, period_end, opening_balance,
closing_balance}`, checked against the account's currency.
### Reconciliations
`books.reconcile` with `confirmed: true` records a reconciliation:
`reconciliation.completed {id, account_id, statement_date,
statement_balance, cleared: [(entry_id, line)], attachment_id}`. It
refuses unless the difference is exactly zero and a statement for that
account and date is attached (the receipt rule's twin). A reconciliation
is never edited; a later one supersedes it; `reconciliation.voided
{id, reason}` exists for mistakes. Viewer: per-account reconciliation
history with its statement; Needs-attention: an account with activity not
reconciled through last month's end.
### Import sources
`books.import_bank_csv` files the CSV (kind `import_source`), links it to
every entry it created, and records the row each came from, so every
imported line traces to its source row.
### Projection
SQLite: `attachment_links(attachment_id, subject_kind, subject_id)`,
`statements(attachment_id, account_id, period_start, period_end, opening,
closing)`, `reconciliations(...)`, `reconciliation_lines(...)`; the old
`attachments.entry_id` column is kept and filled for entry links during
the migration so older readers still work.
## Testing
Spec blocks on every command; spec-blind suites per crate; the
refusal-leaves-books-unchanged property; replay and migration from v10
books; the GUI's every-event-has-text test; mutation on the new modules.
## After this: the completeness engine
Decided (Stephen, 2026-10-15), built after parties and attachments: one
set of completeness rules in the core (asset: tag, serial, tax basis,
acquisition link, label; entry: receipt, 1099 vendor tag; account:
reconciled through last month; month: depreciation, amortization, close;
party: W-9 before paying a contractor; settings: no problems; and the
rules this design adds), `next_steps` on every tool response naming the
exact call that completes each, `books.todo` as the one punch list behind
the digest, the viewer's Needs attention, and the session-start hook, and
enforcement where a rule is a rule (tax basis at registration). The
standard: do it now, completely.
## After that: updates (check and seamless upgrade)
Decided (Stephen, 2026-10-15), after the completeness engine. New versions
fix accounting, tax, and legal rules, so books running an old version may
be wrong; staying current is part of keeping correct books.
- **Check:** the MCP server checks for a newer release when it starts and
once a day while running (and the viewer and `just session-start` ask
it). Releases carry notes and a severity: a fix to an accounting, tax, or
legal rule (a 1099 threshold, a depreciation rule, a refusal that was
wrong) is marked so, and says which books it may affect. `[updates]
check = "daily" | "never"` in wanderbooks.toml; the check contacts only
the release host and sends nothing about the books.
- **Surface:** the update is a completeness item (`books.todo`), in
`books.digest`, a banner in the viewer, and the session-start hook; the
MCP instructions tell the agent to raise it with the human at once, with
the release notes, and to treat a rules fix as urgent.
- **Upgrade:** one step (`wanderbooks update`, or the agent offers it):
download the release, verify its signature against a key pinned in the
installed build, then rehearse: open the books read-only with the new
build and run the full verify (replay, projection, migrations) before
switching. Swap binaries atomically, keep the previous build for
rollback, restart the viewer, and have the MCP server exit cleanly so the
client reconnects to the new one. Never downgrade; never touch the
journal except through the new build's ordinary migrations.
- **After:** the agent re-runs whatever the fix affects (for example,
`books.vendor_1099` for the year, or the register) and tells the human
what changed in their books, if anything.
+1 -1
View File
@@ -13,7 +13,7 @@ layers differ in what they cover and in whether we may copy them:
| Layer | What it governs | Copyright | Here | | Layer | What it governs | Copyright | Here |
|---|---|---|---| |---|---|---|---|
| **Pacioli (1494), Geijsbeek translation (1914)** | The mechanics of double entry: debits equal credits, the trial balance, one money, writing only forward, correcting by opposite entry, closing into capital. No modern standard restates these; they predate every regulator. | Public domain | Vendored verbatim: `sources/pacioli-1494.md` | | **Pacioli (1494), Geijsbeek translation (1914)** | The mechanics of double entry: debits equal credits, the trial balance, one money, writing only forward, correcting by opposite entry, closing into capital. No modern standard restates these; they predate every regulator. | Public domain | Vendored verbatim: `sources/pacioli-1494.md` |
| **IRS: Internal Revenue Code, Treasury Regulations, publications, form instructions** | Records and supporting documents, retention, accounting periods and methods, information returns (1099), capitalization (de minimis, 12-month rule), partners' capital, payroll taxes, rounding on returns, return line items for tax categories (`sources/irs-tax-lines.md`) | Public domain (17 U.S.C. §105; IRS.gov's content policy) | Vendored verbatim: `sources/irs-*.md`, `sources/us-public-domain-status.md` | | **IRS: Internal Revenue Code, Treasury Regulations, publications, form instructions** | Records and supporting documents, retention, accounting periods and methods, information returns (1099), capitalization (de minimis, 12-month rule), partners' capital, tax basis of purchased and contributed property (`sources/irs-partnership-basis.md`), payroll taxes, rounding on returns, return line items for tax categories (`sources/irs-tax-lines.md`) | Public domain (17 U.S.C. §105; IRS.gov's content policy) | Vendored verbatim: `sources/irs-*.md`, `sources/us-public-domain-status.md` |
| **File formats: Intuit (QuickBooks Online bank CSV, QuickBooks Desktop IIF), ECMA-376 Office Open XML, Microsoft Excel limits, Avery label sheets** | What `ledger-import` reads and writes: bank/card CSV layouts and signs, IIF journal entries, how XLSX stores numbers and Excel's 15-digit precision, asset-label sheet geometry (Avery 5160) | © Intuit Inc.; © Ecma International; © Microsoft; © Avery Products Corporation | Cited with short quotes, URLs, and hashes: `sources/intuit-bank-csv.md`, `sources/intuit-iif.md`, `sources/xlsx-numbers.md`, `sources/avery-5160.md` | | **File formats: Intuit (QuickBooks Online bank CSV, QuickBooks Desktop IIF), ECMA-376 Office Open XML, Microsoft Excel limits, Avery label sheets** | What `ledger-import` reads and writes: bank/card CSV layouts and signs, IIF journal entries, how XLSX stores numbers and Excel's 15-digit precision, asset-label sheet geometry (Avery 5160) | © Intuit Inc.; © Ecma International; © Microsoft; © Avery Products Corporation | Cited with short quotes, URLs, and hashes: `sources/intuit-bank-csv.md`, `sources/intuit-iif.md`, `sources/xlsx-numbers.md`, `sources/avery-5160.md` |
| **FASB Accounting Standards Codification (ASC)** | Authoritative US GAAP: cash flows (ASC 230), revenue and principal vs agent (ASC 606), foreign currency (ASC 830), credit losses and aging (ASC 326), property and depreciation (ASC 360) | © Financial Accounting Foundation | Cited by paragraph with short quotes only (in `rules.md`) | | **FASB Accounting Standards Codification (ASC)** | Authoritative US GAAP: cash flows (ASC 230), revenue and principal vs agent (ASC 606), foreign currency (ASC 830), credit losses and aging (ASC 326), property and depreciation (ASC 360) | © Financial Accounting Foundation | Cited by paragraph with short quotes only (in `rules.md`) |
| **FASB Concepts Statement No. 8, Chapter 4 (2021)** | Definitions of the five elements (it replaced Concepts Statement 6) | © Financial Accounting Foundation; non-authoritative | Cited with short quotes only | | **FASB Concepts Statement No. 8, Chapter 4 (2021)** | Definitions of the five elements (it replaced Concepts Statement 6) | © Financial Accounting Foundation; non-authoritative | Cited with short quotes only |
+30
View File
@@ -464,3 +464,33 @@ accounts.
`assets::check_asset_tag`, `assets::check_serial`. **Checked:** `assets::check_asset_tag`, `assets::check_serial`. **Checked:**
`ledger-core` `tests/spec_asset_tags.rs`; `ledger-store` `ledger-core` `tests/spec_asset_tags.rs`; `ledger-store`
`tests/asset_tags.rs`; `ledger-mcp` `tests/spec_assets.rs`. `tests/asset_tags.rs`; `ledger-mcp` `tests/spec_assets.rs`.
## B-25 Tax basis beside book value
**Status:** AUTHORITY (`sources/irs-partnership-basis.md`); DECISION
(Stephen, 2026-10-15: the register carries tax basis)
Book value and tax basis are different numbers for the same asset. The
book value of a member's contribution is its fair market value (Treas.
Reg. §1.704-1(b)(2)(iv)(d)(1), cited). Its federal tax basis follows the
rule that applies: property the company buys has a cost basis ("The basis
of property shall be the cost of such property", IRC §1012(a)); property a
member contributes keeps "the adjusted basis of such property to the
contributing partner at the time of the contribution" (IRC §723,
carryover); property a member converts from personal use depreciates from
"the lesser of its fair market value or its adjusted depreciable basis"
(Treas. Reg. §1.168(i)-4(b)(1)). The difference between fair market value
and the contributor's adjusted basis is the built-in gain (positive) or
loss (negative) that IRC §704(c) makes the partnership account for; a
built-in loss counts only for the contributing member (§704(c)(1)(C)).
wanderbooks records the rule, the contributor's adjusted basis, the
resulting tax basis, and the built-in gain or loss; tax depreciation
(MACRS, conventions, elections) stays with the CPA (`sources/irs-depreciation.md`).
**Enforced:** `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`
(cost takes no contributor basis, a contribution requires one,
non-negative, in cents), `Asset::tax_basis`, `Asset::built_in_gain`,
`reports::fixed_asset_register` (per-row tax fields, `total_tax_basis`,
`missing_tax_basis`). **Checked:** `ledger-core` `tests/spec_tax_basis.rs`;
`ledger-store` `tests/asset_tags.rs` (`asset.tax_basis_set` journaling,
replay, v9 migration, corrupt payloads); `ledger-mcp` `tests/spec_assets.rs`.
@@ -0,0 +1,40 @@
# Tax basis of fixed assets: cost, contributed property, and personal-use conversion
Governs the tax basis the fixed asset register carries beside book value (rules.md B-25). Property the partnership buys has a cost basis (IRC §1012); property a member contributes keeps the member's adjusted basis (IRC §723), and the difference from its fair market value is the built-in gain or loss that §704(c) makes the partnership account for; property a member converts from personal use has a depreciable basis of the lesser of fair market value and adjusted basis (Treas. Reg. §1.168(i)-4(b)(1)). wanderbooks records the basis; tax depreciation (MACRS, conventions, elections) stays with the CPA.
## 26 U.S.C. §1012(a): basis is cost
Source: <https://www.law.cornell.edu/uscode/text/26/1012>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`usc1012.html`): `831c9a6a91fceb2f78968520b6f7811b0975bdc170538fa333baa072b799990b`
> The basis of property shall be the cost of such property, except as otherwise provided in this subchapter and subchapters C (relating to corporate distributions and adjustments), K (relating to partners and partnerships), and P (relating to capital gains and losses).
## 26 U.S.C. §723: basis of property contributed to a partnership
Source: <https://www.law.cornell.edu/uscode/text/26/723>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`usc723.html`): `bfc8c78b5ef5e230f8df1c76d8ab3020ea4a515d987576bba37e740bb5c8c4c7`
> The basis of property contributed to a partnership by a partner shall be the adjusted basis of such property to the contributing partner at the time of the contribution increased by the amount (if any) of gain recognized under section 721(b) to the contributing partner at such time.
## 26 U.S.C. §704(c)(1): built-in gain and loss on contributed property
Source: <https://www.law.cornell.edu/uscode/text/26/704>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`usc704.html`): `03a2c2fd087cc5e849f3755417e34bf8d869afc67e4650f5473a4e73b7cec099`
**§704(c)(1)(A): the variation between basis and fair market value**
> income, gain, loss, and deduction with respect to property contributed to the partnership by a partner shall be shared among the partners so as to take account of the variation between the basis of the property to the partnership and its fair market value at the time of contribution,
**§704(c)(1)(C)**
> if any property so contributed has a built-in loss — (i) such built-in loss shall be taken into account only in determining the amount of items allocated to the contributing partner, and (ii) except as provided in regulations, in determining the amount of items allocated to other partners, the basis of the contributed property in the hands of the partnership shall be treated as being equal to its fair market value at the time of contribution.
**Built-in loss, defined**
> For purposes of subparagraph (C), the term “built-in loss” means the excess of the adjusted basis of the property (determined without regard to subparagraph (C)(ii)) over its fair market value at the time of contribution.
## Treas. Reg. §1.168(i)-4(b)(1): conversion from personal to business use
Source: <https://www.ecfr.gov/api/renderer/v1/content/enhanced/current/title-26?part=1&section=1.168(i)-4>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`ecfr168i4.html`): `284efb83eeb11442ab86e050f6d630bd468fd75463e27e3ec81dbc8de455e9be`
> Depreciation deduction allowable. This paragraph (b) applies to property that is converted from personal use to use in a taxpayer's trade or business, or for the production of income, during a taxable year. This conversion includes property that was previously used by the taxpayer for personal purposes, including real property (other than land) that is acquired before 1987 and converted from personal use to business or income-producing use after 1986, and depreciable property that was previously used by a tax-exempt entity before the entity changed to a taxable entity. Except as otherwise provided by the Internal Revenue Code or regulations under the Internal Revenue Code, upon a conversion to business or income-producing use, the depreciation allowance for the year of change and any subsequent taxable year is determined as though the property is placed in service by the taxpayer on the date on which the conversion occurs. Thus, except as otherwise provided by the Internal Revenue Code or regulations under the Internal Revenue Code, the taxpayer must use any applicable depreciation method, recovery period, and convention prescribed under section 168 for the property in the year of change, consistent with any election made under section 168 by the taxpayer for that year (see, for example, section 168(b)(5)). See §§ 1.168(k)-1(f)(6)(iii) or 1.168(k)-2(g)(6)(iii), as applicable, and 1.1400L(b)-1(f)(6) for the additional first year depreciation deduction rules applicable to a conversion to business or income-producing use. The depreciable basis of the property for the year of change is the lesser of its fair market value or its adjusted depreciable basis (as defined in § 1.168(b)-1T(a)(4)), as applicable, at the time of the conversion to business or income-producing use.
+21 -7
View File
@@ -10,11 +10,19 @@ default:
@just --list @just --list
# Everything CI should check: format, clippy and rustdoc (zero warnings), tests # Everything CI should check: format, clippy and rustdoc (zero warnings), tests
check: check: sweep-test-tmp
cargo fmt --all --check cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet
cargo test --workspace cargo test --workspace
@just sweep-test-tmp
# Test books go in the system temp dir (often RAM). A test cleans up after
# itself, but one killed mid-run (a mutant's timeout, ^C) can't: tens of
# thousands piled up to 23 GB of RAM (2026-10-15). Remove this user's test
# book dirs older than an hour, so a run in progress elsewhere keeps its own.
sweep-test-tmp:
@find "${TMPDIR:-/tmp}" /tmp -maxdepth 1 -user "$(id -u)" -type d -mmin +60 \( -name 'wb-*' -o -name 'wanderbooks-*' -o -name 'ledger-*' \) -exec rm -rf {} + 2>/dev/null || true
# Formal proofs: Kani on the accounting kernel, Lean for books of any size (docs/verification.md) # Formal proofs: Kani on the accounting kernel, Lean for books of any size (docs/verification.md)
prove: prove:
@@ -66,33 +74,38 @@ miri:
mutants_tmp := env_var_or_default("XDG_CACHE_HOME", env_var("HOME") + "/.cache") + "/wanderbooks/mutants" mutants_tmp := env_var_or_default("XDG_CACHE_HOME", env_var("HOME") + "/.cache") + "/wanderbooks/mutants"
# Mutation testing of the core: do the tests catch planted bugs? # Mutation testing of the core: do the tests catch planted bugs?
mutants: mutants: sweep-test-tmp
# Core and store tests (the store's replay tests reach Ledger::replay_*); # Core and store tests (the store's replay tests reach Ledger::replay_*);
# the whole workspace would rebuild the viewer for every mutant. # the whole workspace would rebuild the viewer for every mutant.
mkdir -p {{mutants_tmp}} mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs --test-package ledger-core --test-package ledger-store -j 4 --timeout 120 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs --test-package ledger-core --test-package ledger-store -j 4 --timeout 120
@just sweep-test-tmp
# Mutation testing of the store. Fewer random cases per mutant (each needs # Mutation testing of the store. Fewer random cases per mutant (each needs
# a run that catches it, not the deep sweep). # a run that catches it, not the deep sweep).
mutants-store: mutants-store: sweep-test-tmp
mkdir -p {{mutants_tmp}} mkdir -p {{mutants_tmp}}
STORE_CASES=64 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-store --test-package ledger-store -j 3 --timeout 300 -o mutants-store STORE_CASES=64 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-store --test-package ledger-store -j 3 --timeout 300 -o mutants-store
@just sweep-test-tmp
# Mutation testing of the importers and report renderers. # Mutation testing of the importers and report renderers.
mutants-import: mutants-import: sweep-test-tmp
mkdir -p {{mutants_tmp}} mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-import --test-package ledger-import -j 4 --timeout 300 -o mutants-import PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-import --test-package ledger-import -j 4 --timeout 300 -o mutants-import
@just sweep-test-tmp
# Mutation testing of the settings parser and the receipt policy. # Mutation testing of the settings parser and the receipt policy.
mutants-config: mutants-config: sweep-test-tmp
mkdir -p {{mutants_tmp}} mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-config --test-package ledger-config -j 4 --timeout 120 -o mutants-config PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-config --test-package ledger-config -j 4 --timeout 120 -o mutants-config
@just sweep-test-tmp
# Mutation testing of the MCP server (every mutant runs the stdio suites; # Mutation testing of the MCP server (every mutant runs the stdio suites;
# fewer random cases each, as for the store). # fewer random cases each, as for the store).
mutants-mcp: mutants-mcp: sweep-test-tmp
mkdir -p {{mutants_tmp}} mkdir -p {{mutants_tmp}}
MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp -j 4 --timeout 600 -o mutants-mcp MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp -j 4 --timeout 600 -o mutants-mcp
@just sweep-test-tmp
# Mutation testing of the viewer where it matters: the numbers it shows # Mutation testing of the viewer where it matters: the numbers it shows
# (the view model) and how it writes them (formatting), against the tests # (the view model) and how it writes them (formatting), against the tests
@@ -100,9 +113,10 @@ mutants-mcp:
# its own test (tests/read_only.rs, which renders every page: too slow to # its own test (tests/read_only.rs, which renders every page: too slow to
# rerun per mutant); search, history, ticks, and the attention list are # rerun per mutant); search, history, ticks, and the attention list are
# spec-tested, not swept. # spec-tested, not swept.
mutants-gui: mutants-gui: sweep-test-tmp
mkdir -p {{mutants_tmp}} mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-gui --test-package ledger-gui -j 4 --timeout 300 -o mutants-gui -f crates/ledger-gui/src/books.rs -f crates/ledger-gui/src/fmt.rs -- --lib --test spec_gui --test view_model --test copy PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-gui --test-package ledger-gui -j 4 --timeout 300 -o mutants-gui -f crates/ledger-gui/src/books.rs -f crates/ledger-gui/src/fmt.rs -- --lib --test spec_gui --test view_model --test copy
@just sweep-test-tmp
# Benchmarks of the core's hot paths (no budget gates them yet) # Benchmarks of the core's hot paths (no budget gates them yet)
bench: bench: