Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
- 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:
1 parent
49c49aef33
commit
6e6f9d65b2
30 files changed
+3240
-93
No files matched your search
@@ -80,6 +80,118 @@ pub struct Asset {
|
||||
/// ([`crate::Ledger::set_asset_acquisition`]).
|
||||
#[serde(default)]
|
||||
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.
|
||||
|
||||
@@ -147,6 +147,11 @@ pub enum LedgerError {
|
||||
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`).
|
||||
#[error("bad serial number: {0}")]
|
||||
InvalidSerial(String),
|
||||
|
||||
@@ -1459,6 +1459,7 @@ impl Ledger {
|
||||
tag: None,
|
||||
serial: None,
|
||||
acquired_by: None,
|
||||
tax: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -1656,6 +1657,47 @@ impl Ledger {
|
||||
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.
|
||||
pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> {
|
||||
let id = self.asset_tags.get(&assets::tag_key(tag))?;
|
||||
|
||||
@@ -44,7 +44,8 @@ pub use arap::{
|
||||
};
|
||||
pub use assets::{
|
||||
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 error::LedgerError;
|
||||
|
||||
@@ -1556,6 +1556,15 @@ pub struct AssetRegisterRow {
|
||||
pub book_value: Decimal,
|
||||
/// Disposal date and proceeds, if disposed by the date.
|
||||
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.
|
||||
@@ -1572,6 +1581,11 @@ pub struct AssetRegister {
|
||||
pub total_accumulated: Decimal,
|
||||
/// Their book value.
|
||||
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
|
||||
/// accounts on the date.
|
||||
pub ledger_cost: Decimal,
|
||||
@@ -1611,7 +1625,11 @@ impl AssetRegister {
|
||||
/// 2. accumulated is the asset's schedule through as_of
|
||||
/// (assets::accumulated_depreciation); book_value = cost −
|
||||
/// 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
|
||||
/// balances on as_of of the distinct cost and accumulated
|
||||
/// 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;
|
||||
let mut rows = Vec::new();
|
||||
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 accum_accounts = std::collections::BTreeSet::new();
|
||||
for a in ledger
|
||||
@@ -1644,6 +1663,10 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
|
||||
if disposed.is_none() {
|
||||
cost += a.cost.amount;
|
||||
accumulated += acc;
|
||||
match a.tax_basis() {
|
||||
Some(t) => tax_total += t,
|
||||
None => tax_missing += 1,
|
||||
}
|
||||
}
|
||||
rows.push(AssetRegisterRow {
|
||||
asset_id: a.id,
|
||||
@@ -1661,6 +1684,10 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
|
||||
a.cost.amount - acc
|
||||
},
|
||||
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);
|
||||
@@ -1681,6 +1708,8 @@ pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister
|
||||
total_cost: cost,
|
||||
total_accumulated: 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_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user