P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Book value is cost less what the books posted; the schedule's figure is apart. An asset's acquired date comes from its entry. Contributed property keeps its section 723 carryover basis apart from its depreciable basis (the lower of value and adjusted basis), with its 704(c) built-in gain and method. The K-1 worksheet reports tax-basis capital (Form 1065 Item L) beside the book figures, leaving open what only the CPA knows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
81 files changed
+9391
-2604
No files matched your search
+411
-128
@@ -10,6 +10,16 @@
|
||||
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
|
||||
//! — and postings go through the normal entry machinery, so every
|
||||
//! accounting invariant holds.
|
||||
//!
|
||||
//! Two figures of accumulated depreciation, never confused (rules.md B-24):
|
||||
//! what the schedule says is due through a date
|
||||
//! ([`accumulated_scheduled_through`]) and what the books carry, the runs
|
||||
//! posted and not reversed ([`crate::Ledger::accumulated_posted`]). Book
|
||||
//! value is cost less the posted figure. Two dates: the in-service date starts depreciation; the
|
||||
//! acquired date (the acquisition entry's) puts the asset on the register.
|
||||
//! Three tax figures beside book value, each named for what it is
|
||||
//! ([`Asset::basis_figures`]): the §723 basis, the depreciable basis, and
|
||||
//! the §704(c) built-in gain or loss at contribution.
|
||||
|
||||
use crate::money::Steps;
|
||||
use crate::{
|
||||
@@ -51,14 +61,22 @@ pub enum AssetStatus {
|
||||
}
|
||||
|
||||
/// A fixed asset on the books.
|
||||
///
|
||||
/// Its serde form is internal and frozen: the books an earlier version
|
||||
/// wrote are compared with it field for field
|
||||
/// (`ledger-store/tests/fixtures/old-books`), so the in-service date keeps
|
||||
/// the name `purchase_date` and the basis the name `tax` there.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Asset {
|
||||
/// Opaque handle.
|
||||
pub id: AssetId,
|
||||
/// What it is, e.g. "2023 Sprinter van".
|
||||
pub description: String,
|
||||
/// Purchase date. Depreciation starts here.
|
||||
pub purchase_date: NaiveDate,
|
||||
/// The day it was placed in service: depreciation starts here (B-15).
|
||||
/// The day the company acquired it is its acquisition entry's
|
||||
/// ([`crate::Ledger::asset_acquired_date`]), never later than this.
|
||||
#[serde(rename = "purchase_date")]
|
||||
pub in_service_date: NaiveDate,
|
||||
/// Capitalized cost.
|
||||
pub cost: Money,
|
||||
/// Expected residual value at end of life.
|
||||
@@ -84,14 +102,16 @@ pub struct Asset {
|
||||
pub serial: Option<String>,
|
||||
/// The posted entry that put it on the books (its purchase, or a
|
||||
/// member's contribution of it), if linked
|
||||
/// ([`crate::Ledger::set_asset_acquisition`]).
|
||||
/// ([`crate::Ledger::set_asset_acquisition`]). Its date is the asset's
|
||||
/// acquired date.
|
||||
#[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>,
|
||||
/// How the company got it and the facts its tax figures follow from,
|
||||
/// if recorded ([`crate::Ledger::set_asset_basis`]). Book cost is
|
||||
/// [`Asset::cost`]; the tax figures can differ (a member's contribution
|
||||
/// carries the member's basis, not its market value).
|
||||
#[serde(default, rename = "tax")]
|
||||
pub basis: Option<AssetBasis>,
|
||||
}
|
||||
|
||||
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
|
||||
@@ -104,88 +124,255 @@ pub struct AssetCorrection {
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// In-service date (depreciation starts here).
|
||||
pub purchase_date: NaiveDate,
|
||||
pub in_service_date: NaiveDate,
|
||||
}
|
||||
|
||||
/// Which rule sets an asset's federal tax basis.
|
||||
/// How the company got an asset, which sets its tax figures
|
||||
/// ([`crate::kernel::basis_figures`]).
|
||||
///
|
||||
/// Its wire names ([`Self::key`]) are `purchase`, `carryover_723` and
|
||||
/// `personal_use_conversion`. Books written before P3 named the first two
|
||||
/// `cost` and `carryover` ([`Self::parse_legacy`]); the serde names stay
|
||||
/// those (internal and frozen, see [`Asset`]).
|
||||
///
|
||||
/// ```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).
|
||||
/// value and its adjusted basis), IRC §704(c) (built-in gain and
|
||||
/// 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).
|
||||
pub enum BasisRule {
|
||||
/// Bought by the company: its basis is its cost (IRC §1012(a)), the
|
||||
/// book cost.
|
||||
#[serde(rename = "cost")]
|
||||
Purchase,
|
||||
/// Contributed by a member from business use: the company's basis is
|
||||
/// the member's adjusted basis (IRC §723), and depreciation starts from
|
||||
/// it.
|
||||
#[serde(rename = "carryover")]
|
||||
Carryover723,
|
||||
/// Contributed by a member from personal use: the §723 basis as for
|
||||
/// carryover, and a depreciable basis of the lesser of fair market
|
||||
/// value and that basis (Treas. Reg. §1.168(i)-4(b); DECISION D-41,
|
||||
/// owner 2026-10-20, the CPA to confirm at year end).
|
||||
#[serde(rename = "personal_use_conversion")]
|
||||
PersonalUseConversion,
|
||||
}
|
||||
|
||||
impl TaxBasisRule {
|
||||
/// Its wire name: `cost`, `carryover`, `personal_use_conversion`.
|
||||
impl BasisRule {
|
||||
/// Every rule.
|
||||
pub const ALL: [Self; 3] = [
|
||||
Self::Purchase,
|
||||
Self::Carryover723,
|
||||
Self::PersonalUseConversion,
|
||||
];
|
||||
|
||||
/// Its wire name: `purchase`, `carryover_723`, `personal_use_conversion`.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
TaxBasisRule::Cost => "cost",
|
||||
TaxBasisRule::Carryover => "carryover",
|
||||
TaxBasisRule::PersonalUseConversion => "personal_use_conversion",
|
||||
BasisRule::Purchase => "purchase",
|
||||
BasisRule::Carryover723 => "carryover_723",
|
||||
BasisRule::PersonalUseConversion => "personal_use_conversion",
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule named `key`.
|
||||
/// The rule named `key` (a wire name).
|
||||
pub fn parse(key: &str) -> Option<Self> {
|
||||
[Self::Cost, Self::Carryover, Self::PersonalUseConversion]
|
||||
.into_iter()
|
||||
.find(|r| r.key() == key)
|
||||
Self::ALL.into_iter().find(|r| r.key() == key)
|
||||
}
|
||||
|
||||
/// The rule a pre-P3 name means: `cost`, `carryover` or
|
||||
/// `personal_use_conversion`. Only for replaying `asset.tax_basis_set`,
|
||||
/// reading the projection, and the tools' aliases (one release).
|
||||
pub fn parse_legacy(key: &str) -> Option<Self> {
|
||||
match key {
|
||||
"cost" => Some(Self::Purchase),
|
||||
"carryover" => Some(Self::Carryover723),
|
||||
"personal_use_conversion" => Some(Self::PersonalUseConversion),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a member contributed it (§723 and §704(c) apply).
|
||||
pub fn is_contribution(self) -> bool {
|
||||
self != Self::Purchase
|
||||
}
|
||||
}
|
||||
|
||||
/// An asset's federal tax basis: the rule and, for a member's
|
||||
/// contribution, the member's adjusted basis at the time.
|
||||
/// How the company allocates the tax items of a contributed property
|
||||
/// among the members, IRC §704(c): chosen property by property
|
||||
/// (Treas. Reg. §1.704-3(a)(2)).
|
||||
#[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>,
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sec704cMethod {
|
||||
/// The traditional method, Treas. Reg. §1.704-3(b): tax items follow
|
||||
/// the book allocation to the noncontributing members as far as the
|
||||
/// property's tax items allow (the ceiling rule).
|
||||
Traditional,
|
||||
/// The traditional method with curative allocations, Treas. Reg.
|
||||
/// §1.704-3(c): other tax items correct the ceiling rule's distortion.
|
||||
TraditionalWithCurative,
|
||||
/// The remedial allocation method, Treas. Reg. §1.704-3(d).
|
||||
Remedial,
|
||||
}
|
||||
|
||||
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, and no larger than a cost may be
|
||||
/// (kernel::MAX_LINE_AMOUNT: the register sums them; P0 review round 3,
|
||||
/// S1). Returns why not. Needs no asset, so a caller can check before
|
||||
/// registering one. Replay checks only [`Self::check_journaled`].
|
||||
impl Sec704cMethod {
|
||||
/// Every method.
|
||||
pub const ALL: [Self; 3] = [
|
||||
Self::Traditional,
|
||||
Self::TraditionalWithCurative,
|
||||
Self::Remedial,
|
||||
];
|
||||
|
||||
/// Its wire name: `traditional`, `traditional_with_curative`,
|
||||
/// `remedial`.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Traditional => "traditional",
|
||||
Self::TraditionalWithCurative => "traditional_with_curative",
|
||||
Self::Remedial => "remedial",
|
||||
}
|
||||
}
|
||||
|
||||
/// The method named `key`.
|
||||
pub fn parse(key: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.key() == key)
|
||||
}
|
||||
|
||||
/// The paragraph of Treas. Reg. §1.704-3 that describes it.
|
||||
pub fn citation(self) -> &'static str {
|
||||
match self {
|
||||
Self::Traditional => "Treas. Reg. §1.704-3(b)",
|
||||
Self::TraditionalWithCurative => "Treas. Reg. §1.704-3(c)",
|
||||
Self::Remedial => "Treas. Reg. §1.704-3(d)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the company got an asset and the facts its tax figures follow
|
||||
/// from (facts only: the figures are derived, [`Asset::basis_figures`]).
|
||||
///
|
||||
/// Its serde form keeps the pre-P3 shape for a record written then
|
||||
/// (`{rule, contributor_basis}`): the newer facts are left out when absent.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetBasis {
|
||||
/// The rule.
|
||||
pub rule: BasisRule,
|
||||
/// The contributing member's adjusted basis at contribution (cost plus
|
||||
/// improvements, less depreciation allowed or allowable, IRC
|
||||
/// §1016(a)). `None` for a purchase.
|
||||
#[serde(rename = "contributor_basis")]
|
||||
pub contributor_adjusted_basis: Option<Decimal>,
|
||||
/// Fair market value at contribution. `None` for a purchase, and for a
|
||||
/// contribution recorded before P3, whose value is read as the book
|
||||
/// cost ([`Asset::fmv_at_contribution`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fmv_at_contribution: Option<Decimal>,
|
||||
/// The §704(c) method chosen for it, if chosen (contributions only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sec704c_method: Option<Sec704cMethod>,
|
||||
/// Who gave the facts, when, and on what (rules.md B-29).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<crate::settings::Source>,
|
||||
}
|
||||
|
||||
impl AssetBasis {
|
||||
/// A purchase's record.
|
||||
pub fn purchase() -> Self {
|
||||
Self {
|
||||
rule: BasisRule::Purchase,
|
||||
contributor_adjusted_basis: None,
|
||||
fmv_at_contribution: None,
|
||||
sec704c_method: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A contribution's record: the member's adjusted basis and the value
|
||||
/// at contribution.
|
||||
pub fn contribution(rule: BasisRule, adjusted: Decimal, fmv: Decimal) -> Self {
|
||||
Self {
|
||||
rule,
|
||||
contributor_adjusted_basis: Some(adjusted),
|
||||
fmv_at_contribution: Some(fmv),
|
||||
sec704c_method: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a new record fits its rule (for `asset.basis_set`): a
|
||||
/// purchase takes no contributor basis, value or method; a
|
||||
/// contribution needs the member's adjusted basis and the value at
|
||||
/// contribution, each non-negative, in cents at most, and no larger
|
||||
/// than a cost may be (kernel::MAX_LINE_AMOUNT); its source, if given,
|
||||
/// passes Source::check. Returns why not. Needs no asset, so a caller
|
||||
/// can check before registering one. Replay checks only
|
||||
/// [`Self::check_journaled`].
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.check_journaled()?;
|
||||
match self.contributor_basis {
|
||||
Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => {
|
||||
Err("a basis is at most one quadrillion".into())
|
||||
if let Some(source) = &self.source {
|
||||
source.check().map_err(|why| format!("source: {why}"))?;
|
||||
}
|
||||
for (what, v) in [
|
||||
("an adjusted basis", self.contributor_adjusted_basis),
|
||||
("a fair market value", self.fmv_at_contribution),
|
||||
] {
|
||||
match v {
|
||||
Some(v) if v.normalize().scale() > 2 => {
|
||||
return Err(format!("{what} is in cents at most"));
|
||||
}
|
||||
Some(v) if v > crate::kernel::MAX_LINE_AMOUNT => {
|
||||
return Err(format!("{what} is at most one quadrillion"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The checks every journaled `asset.basis_set` passed, which replay
|
||||
/// applies (rules.md B-28): the facts fit the rule and none is
|
||||
/// negative. No bound on size or places.
|
||||
pub fn check_journaled(&self) -> Result<(), String> {
|
||||
if self.rule == BasisRule::Purchase {
|
||||
if self.contributor_adjusted_basis.is_some()
|
||||
|| self.fmv_at_contribution.is_some()
|
||||
|| self.sec704c_method.is_some()
|
||||
{
|
||||
return Err("a purchase's basis is its cost: give no contributor basis, value at contribution or §704(c) method".into());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match (self.contributor_adjusted_basis, self.fmv_at_contribution) {
|
||||
(None, _) => {
|
||||
Err("a contribution needs the contributing member's adjusted basis".into())
|
||||
}
|
||||
(_, None) => Err("a contribution needs its fair market value at contribution".into()),
|
||||
(Some(b), _) if b.is_sign_negative() && !b.is_zero() => {
|
||||
Err("a basis can't be negative".into())
|
||||
}
|
||||
(_, Some(v)) if v.is_sign_negative() && !v.is_zero() => {
|
||||
Err("a value can't be negative".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every journaled `asset.tax_basis_set` passed when it was
|
||||
/// written (the rule through 2026-10-16): [`Self::check`] without the
|
||||
/// upper bound. Replay applies exactly these (rules.md B-28).
|
||||
pub fn check_journaled(&self) -> Result<(), String> {
|
||||
use TaxBasisRule::*;
|
||||
match (self.rule, self.contributor_basis) {
|
||||
(Cost, Some(_)) => {
|
||||
/// written (the rule through 2026-10-16), for a record with no value
|
||||
/// at contribution: a purchase takes no contributor basis; a
|
||||
/// contribution needs one, non-negative, in cents at most. Replay of
|
||||
/// that event applies exactly these (rules.md B-28).
|
||||
pub fn check_legacy_journaled(&self) -> Result<(), String> {
|
||||
use BasisRule::*;
|
||||
match (self.rule, self.contributor_adjusted_basis) {
|
||||
(Purchase, Some(_)) => {
|
||||
Err("a purchase's tax basis is its cost; give no contributor basis".into())
|
||||
}
|
||||
(Carryover | PersonalUseConversion, None) => {
|
||||
(Carryover723 | PersonalUseConversion, None) => {
|
||||
Err("a contribution needs the contributing member's adjusted basis".into())
|
||||
}
|
||||
(_, Some(b)) if b.is_sign_negative() && !b.is_zero() => {
|
||||
@@ -198,34 +385,83 @@ impl TaxBasis {
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
/// Its depreciable federal tax basis, by its rule, or `None` if not
|
||||
/// recorded.
|
||||
/// Its fair market value at contribution: the value recorded, or for a
|
||||
/// contribution recorded before P3 (no value recorded), the book cost,
|
||||
/// which is what that version assumed. `None` for a purchase or when
|
||||
/// no basis is recorded.
|
||||
pub fn fmv_at_contribution(&self) -> Option<Decimal> {
|
||||
let b = self.basis.as_ref()?;
|
||||
if !b.rule.is_contribution() {
|
||||
return None;
|
||||
}
|
||||
Some(b.fmv_at_contribution.unwrap_or(self.cost.amount))
|
||||
}
|
||||
|
||||
/// Whether its value at contribution was recorded as a fact (false for
|
||||
/// a record from before P3, read as the book cost, and for a purchase).
|
||||
pub fn fmv_is_recorded(&self) -> bool {
|
||||
self.basis
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.fmv_at_contribution.is_some())
|
||||
}
|
||||
|
||||
/// Its three tax figures (kernel::basis_figures), or `None` when no
|
||||
/// basis is recorded. The figures are at contribution (or purchase):
|
||||
/// the CPA adjusts them for tax depreciation (IRC §1016(a)(2)), and
|
||||
/// the §704(c) gain or loss as Treas. Reg. §1.704-3(a)(3)(ii) goes on
|
||||
/// to reduce it.
|
||||
///
|
||||
/// ```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.
|
||||
/// 1. A purchase's depreciable basis is the book cost now.
|
||||
/// 2. A contribution's figures follow its facts: the member's
|
||||
/// adjusted basis and the value at contribution (the book cost for
|
||||
/// a record from before P3, which reproduces that version's
|
||||
/// figures exactly).
|
||||
/// 3. NEVER panics: a figure past exact arithmetic is refused
|
||||
/// (TooLargeToCompute).
|
||||
/// ```
|
||||
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,
|
||||
})
|
||||
pub fn basis_figures(
|
||||
&self,
|
||||
) -> Result<Option<crate::kernel::BasisFigures<Decimal>>, LedgerError> {
|
||||
let Some(b) = &self.basis else {
|
||||
return Ok(None);
|
||||
};
|
||||
let adjusted = b.contributor_adjusted_basis.unwrap_or(Decimal::ZERO);
|
||||
let fmv = self.fmv_at_contribution().unwrap_or(Decimal::ZERO);
|
||||
if b.rule.is_contribution() && b.contributor_adjusted_basis.is_none() {
|
||||
// A contribution without its basis never passes a check; one
|
||||
// can't be reported.
|
||||
return Ok(None);
|
||||
}
|
||||
crate::kernel::basis_figures(b.rule, self.cost.amount, adjusted, fmv)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute(format!("asset {}'s basis figures", self.id))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Its §723 basis (a contribution: the member's adjusted basis), if
|
||||
/// recorded.
|
||||
pub fn tax_basis_723(&self) -> Option<Decimal> {
|
||||
self.basis_figures().ok().flatten()?.tax_basis_723
|
||||
}
|
||||
|
||||
/// Its depreciable tax basis, if recorded.
|
||||
pub fn depreciable_basis(&self) -> Option<Decimal> {
|
||||
Some(self.basis_figures().ok().flatten()?.depreciable_basis)
|
||||
}
|
||||
|
||||
/// Its §704(c) built-in gain (+) or loss (−) at contribution, if a
|
||||
/// contribution is recorded.
|
||||
pub fn built_in_gain_704c(&self) -> Option<Decimal> {
|
||||
self.basis_figures().ok().flatten()?.built_in_gain_704c
|
||||
}
|
||||
|
||||
/// Whether it is contributed property with a built-in loss, which
|
||||
/// counts only for the contributing member(s): IRC §704(c)(1)(C).
|
||||
pub fn sec704c_1c(&self) -> bool {
|
||||
self.built_in_gain_704c().is_some_and(|g| g < Decimal::ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,8 +536,10 @@ pub fn tag_key(tag: &str) -> String {
|
||||
pub struct NewAsset {
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// Purchase date.
|
||||
pub purchase_date: NaiveDate,
|
||||
/// The day it was placed in service: depreciation starts here. The
|
||||
/// acquired date is its acquisition entry's, linked after
|
||||
/// (`Ledger::set_asset_acquisition`).
|
||||
pub in_service_date: NaiveDate,
|
||||
/// Capitalized cost. Must be positive, in the ledger's base currency.
|
||||
pub cost: Money,
|
||||
/// Residual value. Non-negative, less than cost, base currency.
|
||||
@@ -316,6 +554,21 @@ pub struct NewAsset {
|
||||
pub expense_account: AccountId,
|
||||
}
|
||||
|
||||
/// One depreciation run the books recorded for an asset
|
||||
/// (`asset.depreciation_posted`): the month, the entry that posted it, and
|
||||
/// the amount that entry credited to accumulated depreciation. A run counts
|
||||
/// while its entry stands: a reversal takes it back, and a reversal of the
|
||||
/// reversal reinstates it ([`crate::Ledger::accumulated_posted`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DepreciationPosting {
|
||||
/// The month run, "YYYY-MM" as journaled.
|
||||
pub period: String,
|
||||
/// The entry that posted it.
|
||||
pub entry: crate::EntryId,
|
||||
/// What it credited to accumulated depreciation.
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// One month of a depreciation schedule.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScheduleLine {
|
||||
@@ -331,15 +584,15 @@ pub struct ScheduleLine {
|
||||
|
||||
/// The end of an asset's life, exclusive: its in-service date plus its
|
||||
/// life in months (chrono clamps a day past a month's end, so Jan 31 plus
|
||||
/// one month is Feb 28). The life is `[purchase_date, life_end)`.
|
||||
/// one month is Feb 28). The life is `[in_service_date, life_end)`.
|
||||
/// Refused (DateOutOfRange), never a panic, past chrono's last day: replay
|
||||
/// validation keeps every journaled asset's end a date, so this is for
|
||||
/// figures asked of a value no command took.
|
||||
pub fn life_end(asset: &Asset) -> Result<NaiveDate, LedgerError> {
|
||||
asset
|
||||
.purchase_date
|
||||
.in_service_date
|
||||
.checked_add_months(Months::new(asset.useful_life_months))
|
||||
.ok_or(LedgerError::DateOutOfRange(asset.purchase_date))
|
||||
.ok_or(LedgerError::DateOutOfRange(asset.in_service_date))
|
||||
}
|
||||
|
||||
/// One month of a schedule, with its month as numbers (the line names it
|
||||
@@ -350,7 +603,7 @@ struct MonthLine {
|
||||
line: ScheduleLine,
|
||||
}
|
||||
|
||||
/// Straight-line depreciation schedule from the purchase month through
|
||||
/// Straight-line depreciation schedule from the in-service month through
|
||||
/// `through`, inclusive, and through the disposal day of a disposed asset:
|
||||
/// one line per month, the last prorated by days when the schedule stops
|
||||
/// mid-month.
|
||||
@@ -365,8 +618,8 @@ struct MonthLine {
|
||||
/// date prorates that month by days, as partial first and last
|
||||
/// months do.
|
||||
/// INVARIANTS:
|
||||
/// 1. The life is [purchase, purchase + life months), cut into calendar
|
||||
/// months (calendar::month_parts). Through day k of a month but the
|
||||
/// 1. The life is [in service, in service + life months), cut into
|
||||
/// calendar months (calendar::month_parts). Through day k of a month but the
|
||||
/// last, accumulated is (cost − salvage) / life × (the months held
|
||||
/// before it, each as days held over days in the month, + k / days
|
||||
/// in this month): the monthly rate by days.
|
||||
@@ -426,7 +679,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
||||
AssetStatus::Active => through,
|
||||
AssetStatus::Disposed { date, .. } => through.min(*date),
|
||||
};
|
||||
if stop < asset.purchase_date {
|
||||
if stop < asset.in_service_date {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let too_large =
|
||||
@@ -443,7 +696,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
||||
let steps = Steps::of(depreciable, asset.cost.currency).ok_or_else(too_large)?;
|
||||
let mut out = Vec::new();
|
||||
let (mut before, mut charged) = (0i128, 0i128);
|
||||
for p in crate::calendar::month_parts(asset.purchase_date, end) {
|
||||
for p in crate::calendar::month_parts(asset.in_service_date, end) {
|
||||
if p.first > stop {
|
||||
break;
|
||||
}
|
||||
@@ -486,21 +739,31 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Accumulated depreciation through `as_of` (inclusive), exact to the
|
||||
/// minor unit ([`depreciation_schedule`]).
|
||||
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
||||
/// Accumulated depreciation the schedule says is due through `as_of`
|
||||
/// (inclusive), exact to the minor unit ([`depreciation_schedule`]): the
|
||||
/// tools' `accumulated_scheduled_through`. What the books carry is
|
||||
/// [`crate::Ledger::accumulated_posted`]; the two agree once every month
|
||||
/// due is posted (rules.md B-24).
|
||||
pub fn accumulated_scheduled_through(
|
||||
asset: &Asset,
|
||||
as_of: NaiveDate,
|
||||
) -> Result<Decimal, LedgerError> {
|
||||
Ok(depreciation_schedule(asset, as_of)?
|
||||
.last()
|
||||
.map(|l| l.accumulated)
|
||||
.unwrap_or(Decimal::ZERO))
|
||||
}
|
||||
|
||||
/// Book value (cost − accumulated depreciation) as of a date.
|
||||
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
||||
/// Cost less the depreciation the *schedule* says is due through `as_of`:
|
||||
/// the schedule's own book value (as [`ScheduleLine::book_value`]), never
|
||||
/// what the books carry, which is cost less
|
||||
/// [`crate::Ledger::accumulated_posted`] (the register's and the tools'
|
||||
/// `book_value`, rules.md B-24).
|
||||
pub fn scheduled_book_value(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
||||
asset
|
||||
.cost
|
||||
.amount
|
||||
.checked_sub(accumulated_depreciation(asset, as_of)?)
|
||||
.checked_sub(accumulated_scheduled_through(asset, as_of)?)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s book value", asset.id)))
|
||||
}
|
||||
|
||||
@@ -542,23 +805,44 @@ pub struct PostedRun {
|
||||
/// 2. A run in effect sums to its amount; one reversed, to zero.
|
||||
/// ```
|
||||
pub fn depreciation_effects(ledger: &Ledger, runs: &[(EntryId, Decimal)]) -> Vec<PostedRun> {
|
||||
run_chain_entries(ledger, runs, &ledger.posted_reversals())
|
||||
.into_iter()
|
||||
.map(|(d, amount)| PostedRun {
|
||||
year: d.year(),
|
||||
month: d.month(),
|
||||
amount,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every entry of each run's reversal chain the books carry (posted), in
|
||||
/// order, with its date and what it adds to what the books carry for the
|
||||
/// asset: the run its amount, a reversal of it the amount taken away, a
|
||||
/// reversal of that reversal the amount back, and so on (`reversals`:
|
||||
/// each posted reversal's original, [`Ledger::posted_reversals`], read
|
||||
/// once for many runs). The one reading of what the books carry for an
|
||||
/// asset's depreciation: [`depreciation_effects`] by month, and
|
||||
/// [`Ledger::accumulated_posted`] by day.
|
||||
pub(crate) fn run_chain_entries(
|
||||
ledger: &Ledger,
|
||||
runs: &[(EntryId, Decimal)],
|
||||
reversals: &std::collections::HashMap<EntryId, EntryId>,
|
||||
) -> Vec<(NaiveDate, Decimal)> {
|
||||
let mut out = Vec::new();
|
||||
for &(run, amount) in runs {
|
||||
let chain = ledger.reversal_chain(run);
|
||||
let from = chain.iter().position(|&e| e == run).unwrap_or(0);
|
||||
for (i, e) in chain[from..].iter().enumerate() {
|
||||
let Some(entry) = ledger
|
||||
.get_entry(*e)
|
||||
let (mut link, mut adds) = (Some(run), true);
|
||||
// A chain has no more links than there are reversals: the bound
|
||||
// is a guard, never reached.
|
||||
for _ in 0..=reversals.len() {
|
||||
let Some(id) = link else { break };
|
||||
if let Some(e) = ledger
|
||||
.get_entry(id)
|
||||
.filter(|x| x.status() == EntryStatus::Posted)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let d = entry.date();
|
||||
out.push(PostedRun {
|
||||
year: d.year(),
|
||||
month: d.month(),
|
||||
amount: if i % 2 == 0 { amount } else { -amount },
|
||||
});
|
||||
{
|
||||
out.push((e.date(), if adds { amount } else { -amount }));
|
||||
}
|
||||
link = reversals.get(&id).copied();
|
||||
adds = !adds;
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -598,7 +882,7 @@ pub fn run_amounts(
|
||||
through: (i32, u32),
|
||||
posted: &[PostedRun],
|
||||
) -> Result<Vec<(i32, u32, RunAmount)>, LedgerError> {
|
||||
let first = (asset.purchase_date.year(), asset.purchase_date.month());
|
||||
let first = (asset.in_service_date.year(), asset.in_service_date.month());
|
||||
if through < first {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -763,10 +1047,10 @@ pub fn run_amount(
|
||||
posted: &[PostedRun],
|
||||
) -> Result<RunAmount, LedgerError> {
|
||||
let period = format!("{year:04}-{month:02}");
|
||||
if month_end_of(year, month)? < asset.purchase_date {
|
||||
if month_end_of(year, month)? < asset.in_service_date {
|
||||
return Err(LedgerError::InvalidDepreciationPeriod(format!(
|
||||
"{period} ends before asset {} was placed in service on {}",
|
||||
asset.id, asset.purchase_date
|
||||
asset.id, asset.in_service_date
|
||||
)));
|
||||
}
|
||||
catch_up_room(asset, year, month, posted)
|
||||
@@ -838,38 +1122,37 @@ pub struct DisposalAmounts {
|
||||
pub gain_loss: Decimal,
|
||||
}
|
||||
|
||||
/// Whether an asset can be disposed of on `date` for `proceeds`: it is
|
||||
/// active, the proceeds aren't negative, and the date isn't before it was
|
||||
/// placed in service (a disposal on that date holds it one day, accrual
|
||||
/// being inclusive, B-15). The checks only: no depreciation is computed,
|
||||
/// so replaying a journaled disposal never depends on a figure (rules.md
|
||||
/// B-28).
|
||||
pub fn check_disposal(asset: &Asset, date: NaiveDate, proceeds: Money) -> Result<(), LedgerError> {
|
||||
/// Whether an asset can be disposed of for `proceeds`: it is active and
|
||||
/// the proceeds aren't negative. The checks every journaled
|
||||
/// disposal passed, so replaying one never depends on a figure (rules.md
|
||||
/// B-28). The date's floor, the day the company acquired the asset, needs
|
||||
/// its acquisition entry, so the ledger checks it for new disposals only
|
||||
/// (`Ledger::dispose_asset`): a disposal may fall on the in-service date
|
||||
/// (one day held, accrual being inclusive, B-15), or before it once the
|
||||
/// asset is acquired (nothing accrued yet).
|
||||
pub fn check_disposal(asset: &Asset, proceeds: Money) -> Result<(), LedgerError> {
|
||||
if asset.status != AssetStatus::Active {
|
||||
return Err(LedgerError::AssetAlreadyDisposed(asset.id));
|
||||
}
|
||||
if proceeds.amount < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeProceeds(proceeds.amount));
|
||||
}
|
||||
if date < asset.purchase_date {
|
||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
||||
/// A disposal may fall on the purchase date (one day held, accrual being
|
||||
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error
|
||||
/// ([`check_disposal`]). Accumulated depreciation is the schedule through
|
||||
/// the disposal day, inclusive: what the schedule of the asset, once
|
||||
/// disposed, shows ([`depreciation_schedule`] INVARIANT 4).
|
||||
/// Compute disposal amounts by the schedule. Pure math; posting is the
|
||||
/// caller's job. The checks are [`check_disposal`]'s (the acquired-date
|
||||
/// floor is the ledger's, for new disposals). Accumulated depreciation is
|
||||
/// the schedule through the disposal day, inclusive: what the schedule of
|
||||
/// the asset, once disposed, shows ([`depreciation_schedule`] INVARIANT 4);
|
||||
/// nothing before the in-service date.
|
||||
pub fn disposal_amounts(
|
||||
asset: &Asset,
|
||||
date: NaiveDate,
|
||||
proceeds: Money,
|
||||
) -> Result<DisposalAmounts, LedgerError> {
|
||||
check_disposal(asset, date, proceeds)?;
|
||||
let accumulated = accumulated_depreciation(asset, date)?;
|
||||
check_disposal(asset, proceeds)?;
|
||||
let accumulated = accumulated_scheduled_through(asset, date)?;
|
||||
let too_large = || LedgerError::TooLargeToCompute(format!("asset {}'s disposal", asset.id));
|
||||
let book = asset
|
||||
.cost
|
||||
|
||||
@@ -166,11 +166,35 @@ pub enum LedgerError {
|
||||
why: String,
|
||||
},
|
||||
|
||||
/// An asset's tax basis record doesn't fit its rule (see
|
||||
/// `Ledger::set_asset_tax_basis`).
|
||||
/// An asset's basis record doesn't fit its rule (see
|
||||
/// `Ledger::set_asset_basis`).
|
||||
#[error("bad tax basis: {0}")]
|
||||
InvalidTaxBasis(String),
|
||||
|
||||
/// An asset can't be in service before the company acquired it: its
|
||||
/// in-service date is before its acquisition entry's date.
|
||||
#[error(
|
||||
"asset {asset} can't be in service on {in_service}, before it was acquired on {acquired} (its acquisition entry's date): correct the in-service date, or the entry"
|
||||
)]
|
||||
InServiceBeforeAcquisition {
|
||||
/// The asset.
|
||||
asset: u64,
|
||||
/// The in-service date given.
|
||||
in_service: String,
|
||||
/// The acquisition entry's date.
|
||||
acquired: String,
|
||||
},
|
||||
|
||||
/// A depreciation run that doesn't fit its asset or entry (see
|
||||
/// `Ledger::check_depreciation_run`).
|
||||
#[error("bad depreciation run for asset {asset}: {why}")]
|
||||
InvalidDepreciationRun {
|
||||
/// The asset.
|
||||
asset: u64,
|
||||
/// Why not.
|
||||
why: String,
|
||||
},
|
||||
|
||||
/// No party has this id.
|
||||
#[error("unknown party {0}")]
|
||||
UnknownParty(u64),
|
||||
@@ -411,17 +435,24 @@ pub enum LedgerError {
|
||||
#[error("disposal proceeds must not be negative, got {0}")]
|
||||
NegativeProceeds(Decimal),
|
||||
|
||||
/// Disposal date must not precede the purchase date.
|
||||
#[error("disposal date {0} is before the purchase date")]
|
||||
DisposalBeforePurchase(String),
|
||||
/// A disposal can't come before the company acquired the asset (its
|
||||
/// acquisition entry's date, or its in-service date when none is
|
||||
/// linked).
|
||||
#[error("disposal date {date} is before the asset was acquired ({acquired})")]
|
||||
DisposalBeforeAcquisition {
|
||||
/// The disposal date given.
|
||||
date: String,
|
||||
/// The acquired date.
|
||||
acquired: String,
|
||||
},
|
||||
|
||||
/// The three asset account links must be three distinct accounts.
|
||||
#[error("asset account links must be distinct; account id {0} is linked twice")]
|
||||
DuplicateAssetAccount(u64),
|
||||
|
||||
/// purchase_date + useful life overflows the date range: a new asset's
|
||||
/// last day of depreciation is 9999-12-31 at the latest.
|
||||
#[error("purchase date plus useful life overflows the calendar")]
|
||||
/// in-service date + useful life overflows the date range: a new
|
||||
/// asset's last day of depreciation is 9999-12-31 at the latest.
|
||||
#[error("in-service date plus useful life overflows the calendar")]
|
||||
AssetDateOverflow,
|
||||
|
||||
/// A depreciation run for a month that can't be run: not a month, or
|
||||
|
||||
@@ -169,17 +169,9 @@ pub fn k1_detail(
|
||||
}
|
||||
let me = members_equity(ledger, start, end, members)?;
|
||||
let places = ledger.base_currency().allocation_places();
|
||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||
let property_account = |id: AccountId| {
|
||||
!cash.contains(&id)
|
||||
&& Some(id) != receivables
|
||||
&& ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||
};
|
||||
let property_account = |id: AccountId| is_property_account(ledger, cash, id);
|
||||
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
||||
e.lines()
|
||||
.iter()
|
||||
@@ -274,6 +266,18 @@ pub fn k1_detail(
|
||||
Ok(K1Detail { rows, lines })
|
||||
}
|
||||
|
||||
/// Whether an account holds property (INVARIANT 3): an asset account that
|
||||
/// is neither cash (`cash`) nor receivables. The tax-basis capital
|
||||
/// worksheet (crate::tax_capital) reads property the same way.
|
||||
pub(crate) fn is_property_account(ledger: &Ledger, cash: &[AccountId], id: AccountId) -> bool {
|
||||
!cash.contains(&id)
|
||||
&& Some(id) != ledger.role_account(&crate::RECEIVABLES)
|
||||
&& ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||
}
|
||||
|
||||
/// One entry's property shared among the members' credits in it
|
||||
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||
/// credit, rounded toward zero to `places` (the base currency's
|
||||
|
||||
@@ -48,8 +48,14 @@
|
||||
//! times the monthly rate within one unit, the last the remainder;
|
||||
//! * a catch-up run brings the books to the schedule without ever carrying
|
||||
//! more than it says at a month end already posted (B-15), and a
|
||||
//! disposal trues what the books carry up or down to the schedule.
|
||||
//! disposal trues what the books carry up or down to the schedule;
|
||||
//! * an asset's basis figures follow from its basis rule and facts: the
|
||||
//! §723 basis is the contributor's adjusted basis, the depreciable basis
|
||||
//! the cost, that basis, or the lesser of it and fair market value, and
|
||||
//! the §704(c) built-in gain is fair market value less the §723 basis
|
||||
//! (B-25).
|
||||
|
||||
use crate::assets::BasisRule;
|
||||
use crate::{AccountType, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
@@ -1402,6 +1408,72 @@ pub fn true_up<T: Amount>(target: T, posted: T) -> Option<(T, T)> {
|
||||
columns(target.checked_sub(posted)?)
|
||||
}
|
||||
|
||||
/// An asset's three basis figures ([`basis_figures`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BasisFigures<T> {
|
||||
/// The partnership's basis in contributed property, IRC §723: the
|
||||
/// contributor's adjusted basis. `None` for a purchase.
|
||||
pub tax_basis_723: Option<T>,
|
||||
/// The basis tax depreciation starts from.
|
||||
pub depreciable_basis: T,
|
||||
/// Fair market value less the §723 basis at contribution, IRC §704(c):
|
||||
/// positive a built-in gain, negative a built-in loss. `None` for a
|
||||
/// purchase (not property contributed).
|
||||
pub built_in_gain_704c: Option<T>,
|
||||
}
|
||||
|
||||
/// An asset's basis figures from its rule and facts: `cost` the book cost
|
||||
/// (a purchase's basis), `adjusted` the contributing member's adjusted
|
||||
/// basis and `fmv` the fair market value at contribution (both ignored for
|
||||
/// a purchase).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the three tax figures of an asset, each named for what it is,
|
||||
/// from one function the register, the tools and the viewer share.
|
||||
/// SOURCE: docs/spec/rules.md B-25: IRC §1012(a) (cost); IRC §723 (the
|
||||
/// contributor's adjusted basis carries over); Treas. Reg.
|
||||
/// §1.168(i)-4(b)(1) (property converted from personal use: "the
|
||||
/// lesser of its fair market value or its adjusted depreciable
|
||||
/// basis"; DECISION D-41, owner 2026-10-20); Treas. Reg.
|
||||
/// §1.704-3(a)(3)(ii) (built-in gain: "the excess of the
|
||||
/// property's book value over the contributing partner's adjusted
|
||||
/// tax basis upon contribution", book value being fair market
|
||||
/// value at contribution, §1.704-3(a)(3)(i)).
|
||||
/// INVARIANTS:
|
||||
/// 1. Purchase: depreciable = cost; no §723 basis, no built-in gain.
|
||||
/// 2. Contribution: tax_basis_723 = adjusted; built_in_gain_704c =
|
||||
/// fmv − adjusted, negative exactly when adjusted > fmv (a built-in
|
||||
/// loss, IRC §704(c)(1)(C)).
|
||||
/// 3. Carryover723: depreciable = adjusted.
|
||||
/// 4. PersonalUseConversion: depreciable = min(fmv, adjusted), at most
|
||||
/// each of them and equal to one.
|
||||
/// 5. NEVER wraps: None when a figure can't be represented.
|
||||
/// ```
|
||||
pub fn basis_figures<T: Amount>(
|
||||
rule: BasisRule,
|
||||
cost: T,
|
||||
adjusted: T,
|
||||
fmv: T,
|
||||
) -> Option<BasisFigures<T>> {
|
||||
Some(match rule {
|
||||
BasisRule::Purchase => BasisFigures {
|
||||
tax_basis_723: None,
|
||||
depreciable_basis: cost,
|
||||
built_in_gain_704c: None,
|
||||
},
|
||||
BasisRule::Carryover723 => BasisFigures {
|
||||
tax_basis_723: Some(adjusted),
|
||||
depreciable_basis: adjusted,
|
||||
built_in_gain_704c: Some(fmv.checked_sub(adjusted)?),
|
||||
},
|
||||
BasisRule::PersonalUseConversion => BasisFigures {
|
||||
tax_basis_723: Some(adjusted),
|
||||
depreciable_basis: if fmv < adjusted { fmv } else { adjusted },
|
||||
built_in_gain_704c: Some(fmv.checked_sub(adjusted)?),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1750,6 +1822,46 @@ mod tests {
|
||||
assert_eq!(true_up(5_500i64, 5_500), Some((0, 0)));
|
||||
}
|
||||
|
||||
/// basis_figures INVARIANTS 1-4, by hand: a 1,440.00 laptop the member
|
||||
/// holds at 1,688.40 (personal use): §723 1,688.40, depreciable
|
||||
/// 1,440.00, built-in loss 248.40; as business property, depreciable
|
||||
/// 1,688.40; a purchase at 900.00: depreciable 900.00 and nothing else.
|
||||
#[test]
|
||||
fn basis_figures_by_rule() {
|
||||
let (fmv, adj) = (144_000i64, 168_840i64);
|
||||
let p = basis_figures(BasisRule::PersonalUseConversion, fmv, adj, fmv).unwrap();
|
||||
assert_eq!(
|
||||
p,
|
||||
BasisFigures {
|
||||
tax_basis_723: Some(adj),
|
||||
depreciable_basis: fmv,
|
||||
built_in_gain_704c: Some(-24_840),
|
||||
}
|
||||
);
|
||||
let c = basis_figures(BasisRule::Carryover723, fmv, adj, fmv).unwrap();
|
||||
assert_eq!(c.depreciable_basis, adj);
|
||||
assert_eq!(c.built_in_gain_704c, Some(-24_840));
|
||||
// Value above basis: depreciable is the basis, a built-in gain.
|
||||
let g = basis_figures(BasisRule::PersonalUseConversion, 1000, 400, 1000).unwrap();
|
||||
assert_eq!(
|
||||
(g.depreciable_basis, g.built_in_gain_704c),
|
||||
(400, Some(600))
|
||||
);
|
||||
let b = basis_figures(BasisRule::Purchase, 90_000i64, 7, 8).unwrap();
|
||||
assert_eq!(
|
||||
b,
|
||||
BasisFigures {
|
||||
tax_basis_723: None,
|
||||
depreciable_basis: 90_000,
|
||||
built_in_gain_704c: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
basis_figures(BasisRule::Carryover723, 0, i64::MAX, i64::MIN),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_line_amount_is_one_quadrillion() {
|
||||
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
|
||||
|
||||
@@ -270,6 +270,10 @@ pub struct Ledger {
|
||||
/// Every asset tag ever held ([`assets::tag_key`] → the asset that
|
||||
/// held it): a tag never moves to another asset.
|
||||
asset_tags: std::collections::BTreeMap<String, AssetId>,
|
||||
/// Each asset's depreciation runs (`asset.depreciation_posted`), in
|
||||
/// the order recorded: what the books carry for it
|
||||
/// ([`Ledger::accumulated_posted`]).
|
||||
asset_runs: std::collections::BTreeMap<AssetId, Vec<assets::DepreciationPosting>>,
|
||||
/// The party directory ([`crate::parties`]).
|
||||
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
|
||||
next_party_id: crate::PartyId,
|
||||
@@ -325,6 +329,7 @@ impl Ledger {
|
||||
bill_payments: HashMap::new(),
|
||||
roles: std::collections::BTreeMap::new(),
|
||||
asset_tags: std::collections::BTreeMap::new(),
|
||||
asset_runs: std::collections::BTreeMap::new(),
|
||||
parties: std::collections::BTreeMap::new(),
|
||||
next_party_id: 1,
|
||||
entry_parties: HashMap::new(),
|
||||
@@ -720,6 +725,7 @@ impl Ledger {
|
||||
("bill_payments", self.bill_payments == other.bill_payments),
|
||||
("roles", self.roles == other.roles),
|
||||
("asset_tags", self.asset_tags == other.asset_tags),
|
||||
("asset_runs", self.asset_runs == other.asset_runs),
|
||||
("parties", self.parties == other.parties),
|
||||
("next_party_id", self.next_party_id == other.next_party_id),
|
||||
("entry_parties", self.entry_parties == other.entry_parties),
|
||||
@@ -1927,10 +1933,10 @@ impl Ledger {
|
||||
// its exclusive end) inside the calendar the books keep, so every
|
||||
// month of the life is a month the books can post.
|
||||
if strict {
|
||||
crate::calendar::check(new.purchase_date)?;
|
||||
crate::calendar::check(new.in_service_date)?;
|
||||
}
|
||||
let end = new
|
||||
.purchase_date
|
||||
.in_service_date
|
||||
.checked_add_months(Months::new(new.useful_life_months));
|
||||
if !end
|
||||
.is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar))
|
||||
@@ -1981,7 +1987,7 @@ impl Ledger {
|
||||
Asset {
|
||||
id,
|
||||
description: new.description,
|
||||
purchase_date: new.purchase_date,
|
||||
in_service_date: new.in_service_date,
|
||||
cost: new.cost,
|
||||
salvage_value: new.salvage_value,
|
||||
useful_life_months: new.useful_life_months,
|
||||
@@ -1993,7 +1999,7 @@ impl Ledger {
|
||||
tag: None,
|
||||
serial: None,
|
||||
acquired_by: None,
|
||||
tax: None,
|
||||
basis: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -2092,19 +2098,25 @@ impl Ledger {
|
||||
}
|
||||
|
||||
/// Link an asset to the posted entry that put it on the books: its
|
||||
/// purchase, or a member's contribution of it.
|
||||
/// purchase, or a member's contribution of it. The entry's date is the
|
||||
/// asset's acquired date ([`Self::asset_acquired_date`]).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: from an asset, its source document and capital entry; from
|
||||
/// the entry, the asset.
|
||||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||||
/// the entry, the asset; and the day the company acquired it.
|
||||
/// SOURCE: DECISION (owner, 2026-10-15); rules.md B-15 (P3, settled in
|
||||
/// design 2026-10-20: an asset's acquired date comes from the
|
||||
/// entry it is linked to).
|
||||
/// INVARIANTS:
|
||||
/// 1. The linked entry is posted, is not a reversal, has not been
|
||||
/// reversed, and debits the asset's cost account.
|
||||
/// 2. A refusal MUST leave the books unchanged.
|
||||
/// 2. The linked entry is dated on or before the asset's in-service
|
||||
/// date, and on or before its disposal: the register then holds
|
||||
/// the asset exactly while the books carry its cost (B-24).
|
||||
/// 3. A refusal MUST leave the books unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the asset or entry is unknown, or the entry fails 1: SHOULD
|
||||
/// refuse with AcquisitionEntryMismatch (or UnknownAsset /
|
||||
/// 1. IF the asset or entry is unknown, or the entry fails 1 or 2:
|
||||
/// SHOULD refuse with AcquisitionEntryMismatch (or UnknownAsset /
|
||||
/// UnknownEntry).
|
||||
/// 2. IF a later correction reverses the linked entry: link the
|
||||
/// replacement (a reversed link stays as history in the journal).
|
||||
@@ -2119,6 +2131,19 @@ impl Ledger {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Self::set_asset_acquisition`] with the checks every journaled
|
||||
/// `asset.acquisition_linked` passed when it was written (INVARIANT 1,
|
||||
/// without the dates of INVARIANT 2), for replay only (rules.md B-28).
|
||||
pub fn replay_set_asset_acquisition(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
entry: EntryId,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_asset_acquisition_journaled(id, entry)?;
|
||||
self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore a link recorded earlier (a projection read-back): it was
|
||||
/// checked when made, and a later correction (reversing that entry)
|
||||
/// doesn't undo it; it only becomes a link to fix, by linking the
|
||||
@@ -2149,8 +2174,67 @@ impl Ledger {
|
||||
.is_some_and(|e| self.entries.values().any(|x| x.reversal_of() == Some(e)))
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_asset_acquisition`].
|
||||
/// The day the company acquired an asset: its acquisition entry's date,
|
||||
/// or its in-service date when no entry is linked (every asset
|
||||
/// registered before P3 without a link), never later than the
|
||||
/// in-service date. `None` for an unknown asset.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the register holds an asset from the day the books carry its
|
||||
/// cost, which can be before it serves (B-24).
|
||||
/// SOURCE: rules.md B-15, B-24; P3, settled in design (2026-10-20):
|
||||
/// "an asset's acquired date comes from the entry it is linked
|
||||
/// to".
|
||||
/// INVARIANTS:
|
||||
/// 1. acquired = min(in_service_date, the linked entry's date); the
|
||||
/// in-service date with no link. One source of truth: no stored
|
||||
/// date to disagree with the entry.
|
||||
/// ```
|
||||
pub fn asset_acquired_date(&self, id: AssetId) -> Option<NaiveDate> {
|
||||
let a = self.assets.get(&id)?;
|
||||
Some(
|
||||
a.acquired_by
|
||||
.and_then(|e| self.entries.get(&e))
|
||||
.map_or(a.in_service_date, |e| e.date().min(a.in_service_date)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_asset_acquisition`]: INVARIANTS 1
|
||||
/// and 2.
|
||||
pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> {
|
||||
self.check_asset_acquisition_journaled(id, entry)?;
|
||||
let asset = self.assets.get(&id).expect("checked");
|
||||
let date = self.entries.get(&entry).expect("checked").date();
|
||||
let why = if date > asset.in_service_date {
|
||||
Some(format!(
|
||||
"it is dated {date}, after the asset's in-service date {} (an asset is acquired before it serves: correct the in-service date with books.correct_asset, or the entry)",
|
||||
asset.in_service_date
|
||||
))
|
||||
} else {
|
||||
match &asset.status {
|
||||
AssetStatus::Disposed { date: gone, .. } if date > *gone => Some(format!(
|
||||
"it is dated {date}, after the asset's disposal on {gone}"
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
match why {
|
||||
Some(why) => Err(LedgerError::AcquisitionEntryMismatch {
|
||||
asset: id,
|
||||
entry,
|
||||
why,
|
||||
}),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 1 of [`Self::set_asset_acquisition`]: the checks every
|
||||
/// journaled `asset.acquisition_linked` passed.
|
||||
fn check_asset_acquisition_journaled(
|
||||
&self,
|
||||
id: AssetId,
|
||||
entry: EntryId,
|
||||
) -> Result<(), LedgerError> {
|
||||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||||
let e = self
|
||||
.entries
|
||||
@@ -2221,69 +2305,109 @@ 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.
|
||||
/// Record how the company got an asset and the facts its tax figures
|
||||
/// follow from ([`assets::AssetBasis`]): the rule, and for a member's
|
||||
/// contribution the member's adjusted basis, the fair market value at
|
||||
/// contribution, the §704(c) method if chosen, and the source. 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.
|
||||
/// GOAL: the register carries the tax figures beside book value, each
|
||||
/// named for what it is, 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.
|
||||
/// irs-partnership-basis.md; DECISION D-41 (owner, 2026-10-20).
|
||||
/// INVARIANTS:
|
||||
/// 1. Cost takes no contributor basis; Carryover and
|
||||
/// PersonalUseConversion require one.
|
||||
/// 2. A contributor basis is non-negative, in cents at most.
|
||||
/// 1. The record passes AssetBasis::check: a purchase takes no
|
||||
/// contribution facts; a contribution needs the adjusted basis and
|
||||
/// the value at contribution, each non-negative, in cents, at
|
||||
/// most one quadrillion.
|
||||
/// 2. Only facts are kept: the figures are derived when read
|
||||
/// (Asset::basis_figures), under the rule in force.
|
||||
/// 3. A refusal MUST leave the books unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the asset is unknown, or 1 or 2 fails: SHOULD refuse with
|
||||
/// 1. IF the asset is unknown, or 1 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(
|
||||
pub fn set_asset_basis(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
tax: assets::TaxBasis,
|
||||
basis: assets::AssetBasis,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_asset_tax_basis(id, tax)?;
|
||||
self.assets.get_mut(&id).expect("checked").tax = Some(tax);
|
||||
self.check_asset_basis(id, &basis)?;
|
||||
self.assets.get_mut(&id).expect("checked").basis = Some(basis);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Self::set_asset_tax_basis`] with the checks every journaled
|
||||
/// `asset.tax_basis_set` passed when it was written
|
||||
/// (TaxBasis::check_journaled: no upper bound), for replay only
|
||||
/// (rules.md B-28). Never for new work.
|
||||
pub fn replay_set_asset_tax_basis(
|
||||
/// The validation behind [`Self::set_asset_basis`].
|
||||
pub fn check_asset_basis(
|
||||
&self,
|
||||
id: AssetId,
|
||||
basis: &assets::AssetBasis,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.assets.contains_key(&id) {
|
||||
return Err(LedgerError::UnknownAsset(id));
|
||||
}
|
||||
basis.check().map_err(LedgerError::InvalidTaxBasis)
|
||||
}
|
||||
|
||||
/// [`Self::set_asset_basis`] with the checks every journaled
|
||||
/// `asset.basis_set` passed (AssetBasis::check_journaled: the facts fit
|
||||
/// the rule, none negative, no bound on size), for replay and the
|
||||
/// projection's read-back only (rules.md B-28). The figures the line
|
||||
/// carries are for its readers: replay never recomputes them, so a
|
||||
/// later change to how a figure is derived never refuses books an
|
||||
/// earlier version wrote. Never for new work.
|
||||
pub fn replay_set_asset_basis(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
tax: assets::TaxBasis,
|
||||
basis: assets::AssetBasis,
|
||||
) -> Result<(), LedgerError> {
|
||||
let asset = self
|
||||
.assets
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAsset(id))?;
|
||||
tax.check_journaled()
|
||||
basis
|
||||
.check_journaled()
|
||||
.map_err(LedgerError::InvalidTaxBasis)?;
|
||||
asset.tax = Some(tax);
|
||||
asset.basis = Some(basis);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_asset_tax_basis`].
|
||||
pub fn check_asset_tax_basis(
|
||||
&self,
|
||||
/// Replay a pre-P3 `asset.tax_basis_set` (rule and contributor basis,
|
||||
/// no value at contribution, which reads as the book cost): the checks
|
||||
/// it passed when written (AssetBasis::check_legacy_journaled), for
|
||||
/// replay and the projection's read-back only (rules.md B-28). Never for
|
||||
/// new work.
|
||||
pub fn replay_set_asset_tax_basis(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
tax: assets::TaxBasis,
|
||||
rule: assets::BasisRule,
|
||||
contributor_basis: Option<Decimal>,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.assets.contains_key(&id) {
|
||||
return Err(LedgerError::UnknownAsset(id));
|
||||
}
|
||||
tax.check().map_err(LedgerError::InvalidTaxBasis)
|
||||
let asset = self
|
||||
.assets
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAsset(id))?;
|
||||
let basis = assets::AssetBasis {
|
||||
rule,
|
||||
contributor_adjusted_basis: contributor_basis,
|
||||
fmv_at_contribution: None,
|
||||
sec704c_method: None,
|
||||
source: None,
|
||||
};
|
||||
basis
|
||||
.check_legacy_journaled()
|
||||
.map_err(LedgerError::InvalidTaxBasis)?;
|
||||
asset.basis = Some(basis);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct a registered asset's economics (cost, salvage, useful life,
|
||||
/// in-service date) when it was registered wrong. Its accounts, tag,
|
||||
/// serial, links, and tax basis record stay.
|
||||
/// serial, links, and basis record stay.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: fix a registration mistake without re-registering (which would
|
||||
@@ -2295,7 +2419,12 @@ impl Ledger {
|
||||
/// 2. Only an active asset is corrected; a store also refuses once any
|
||||
/// depreciation for it is posted (the posted entries would disagree
|
||||
/// with the schedule; correct with entries instead).
|
||||
/// 3. A refusal MUST leave the books unchanged.
|
||||
/// 3. The in-service date is never before the linked acquisition
|
||||
/// entry's date (set_asset_acquisition INVARIANT 2).
|
||||
/// 4. A contribution's value at contribution and built-in gain stay
|
||||
/// as recorded: they were fixed when it was contributed (Treas.
|
||||
/// Reg. §1.704-3(a)(3)(ii)), whatever the book cost becomes.
|
||||
/// 5. A refusal MUST leave the books unchanged.
|
||||
/// ```
|
||||
pub fn correct_asset(
|
||||
&mut self,
|
||||
@@ -2328,16 +2457,31 @@ impl Ledger {
|
||||
a.cost = fix.cost;
|
||||
a.salvage_value = fix.salvage_value;
|
||||
a.useful_life_months = fix.useful_life_months;
|
||||
a.purchase_date = fix.purchase_date;
|
||||
a.in_service_date = fix.in_service_date;
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::correct_asset`].
|
||||
/// The validation behind [`Self::correct_asset`] (INVARIANTS 1-3).
|
||||
pub fn check_correct_asset(
|
||||
&self,
|
||||
id: AssetId,
|
||||
fix: &assets::AssetCorrection,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.validate_new_asset(&self.corrected_asset(id, fix)?)
|
||||
self.validate_new_asset(&self.corrected_asset(id, fix)?)?;
|
||||
if let Some(linked) = self
|
||||
.assets
|
||||
.get(&id)
|
||||
.and_then(|a| a.acquired_by)
|
||||
.and_then(|e| self.entries.get(&e))
|
||||
.map(|e| e.date())
|
||||
&& fix.in_service_date < linked
|
||||
{
|
||||
return Err(LedgerError::InServiceBeforeAcquisition {
|
||||
asset: id,
|
||||
in_service: fix.in_service_date.to_string(),
|
||||
acquired: linked.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The active asset `id` as `fix` would leave it, as a registration to
|
||||
@@ -2353,7 +2497,7 @@ impl Ledger {
|
||||
}
|
||||
Ok(NewAsset {
|
||||
description: a.description.clone(),
|
||||
purchase_date: fix.purchase_date,
|
||||
in_service_date: fix.in_service_date,
|
||||
cost: fix.cost,
|
||||
salvage_value: fix.salvage_value,
|
||||
useful_life_months: fix.useful_life_months,
|
||||
@@ -2381,6 +2525,232 @@ impl Ledger {
|
||||
.expect("some number is free")
|
||||
}
|
||||
|
||||
/// Whether a depreciation run can be recorded, for a run a store
|
||||
/// journals now: the asset is known; the period is a month, "YYYY-MM";
|
||||
/// the amount is positive and in cents; and the entry is posted and
|
||||
/// credits the asset's accumulated depreciation account by exactly the
|
||||
/// amount.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a run the books record is the entry that posted it, no more
|
||||
/// and no less, so accumulated_posted is what the books carry.
|
||||
/// SOURCE: rules.md B-24; B-28 (new commands are bounded; replay checks
|
||||
/// only what every journaled run passed:
|
||||
/// record_depreciation_run).
|
||||
/// ```
|
||||
pub fn check_depreciation_run(
|
||||
&self,
|
||||
asset: AssetId,
|
||||
period: &str,
|
||||
entry: EntryId,
|
||||
amount: Decimal,
|
||||
) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.assets
|
||||
.get(&asset)
|
||||
.ok_or(LedgerError::UnknownAsset(asset))?;
|
||||
let bad = |why: String| LedgerError::InvalidDepreciationRun { asset, why };
|
||||
let month_ok = period.len() == 7
|
||||
&& period.as_bytes()[4] == b'-'
|
||||
&& NaiveDate::parse_from_str(&format!("{period}-01"), "%Y-%m-%d").is_ok();
|
||||
if !month_ok {
|
||||
return Err(bad(format!("period {period:?} isn't a month (YYYY-MM)")));
|
||||
}
|
||||
if amount <= Decimal::ZERO || amount.normalize().scale() > 2 {
|
||||
return Err(bad(format!(
|
||||
"amount {amount} isn't a positive number of cents"
|
||||
)));
|
||||
}
|
||||
let e = self
|
||||
.entries
|
||||
.get(&entry)
|
||||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||||
if e.status() != crate::EntryStatus::Posted {
|
||||
return Err(bad(format!("entry {entry} isn't posted")));
|
||||
}
|
||||
let credited = kernel::net(
|
||||
e.lines()
|
||||
.iter()
|
||||
.filter(|l| l.account == a.accum_depr_account)
|
||||
.map(|l| (kernel::flip(l.side), l.amount.amount)),
|
||||
)
|
||||
.ok_or_else(|| bad("its entry's lines are too large".into()))?;
|
||||
if credited != amount {
|
||||
return Err(bad(format!(
|
||||
"entry {entry} credits accumulated depreciation {credited}, not {amount}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a depreciation run (`asset.depreciation_posted`). Checks only
|
||||
/// that the asset and the entry exist: every run an earlier version
|
||||
/// journaled replays, whatever its period or amount (rules.md B-28; a
|
||||
/// store checks new runs first, [`Self::check_depreciation_run`]).
|
||||
pub fn record_depreciation_run(
|
||||
&mut self,
|
||||
asset: AssetId,
|
||||
period: &str,
|
||||
entry: EntryId,
|
||||
amount: Decimal,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.assets.contains_key(&asset) {
|
||||
return Err(LedgerError::UnknownAsset(asset));
|
||||
}
|
||||
if !self.entries.contains_key(&entry) {
|
||||
return Err(LedgerError::UnknownEntry(entry));
|
||||
}
|
||||
self.asset_runs
|
||||
.entry(asset)
|
||||
.or_default()
|
||||
.push(assets::DepreciationPosting {
|
||||
period: period.to_string(),
|
||||
entry,
|
||||
amount,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An asset's depreciation runs, in the order recorded.
|
||||
pub fn depreciation_runs(&self, asset: AssetId) -> &[assets::DepreciationPosting] {
|
||||
self.asset_runs.get(&asset).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Each posted reversal's original: original entry → its reversal (an
|
||||
/// entry is reversed at most once, B-10). Read once for many runs
|
||||
/// ([`assets::depreciation_effects`], [`Self::accumulated_posted_all`]),
|
||||
/// never per run: the viewer asks it of every asset.
|
||||
pub(crate) fn posted_reversals(&self) -> HashMap<EntryId, EntryId> {
|
||||
self.posted_entries()
|
||||
.filter_map(|e| e.reversal_of().map(|orig| (orig, e.id())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An asset's runs as the catch-up rule reads them: (entry, amount),
|
||||
/// in the order recorded ([`assets::depreciation_effects`]).
|
||||
pub fn depreciation_run_entries(&self, asset: AssetId) -> Vec<(EntryId, Decimal)> {
|
||||
self.depreciation_runs(asset)
|
||||
.iter()
|
||||
.map(|r| (r.entry, r.amount))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What the books carry for an asset's depreciation, entry by entry,
|
||||
/// each in its own month: every run and every reversal of one
|
||||
/// ([`assets::depreciation_effects`] of its runs).
|
||||
pub fn depreciation_effects_of(&self, asset: AssetId) -> Vec<assets::PostedRun> {
|
||||
assets::depreciation_effects(self, &self.depreciation_run_entries(asset))
|
||||
}
|
||||
|
||||
/// Accumulated depreciation the books carry for an asset on `as_of`:
|
||||
/// what its runs carry through that day, entry by entry
|
||||
/// ([`assets::depreciation_effects`]' reading, by day); zero once it
|
||||
/// is disposed (the disposal entry cleared it).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: book value is cost less what the books carry, never the
|
||||
/// schedule's figure.
|
||||
/// SOURCE: rules.md B-24 (the register reconciles to the general
|
||||
/// ledger); B-8, B-9, B-10 (a reversal undoes what it reverses,
|
||||
/// and reversing the reversal reinstates it); B-15 (what the
|
||||
/// catch-up rule reads, the same entries).
|
||||
/// INVARIANTS:
|
||||
/// 1. Disposed on or before as_of: 0.
|
||||
/// 2. Otherwise Σ, over the asset's runs and every entry of each
|
||||
/// one's reversal chain the books carry (assets::run_chain_entries:
|
||||
/// the run +amount, its reversal −amount, that reversal's
|
||||
/// reversal +amount, ...), of those dated on or before as_of. So
|
||||
/// it moves exactly as the trial balance does, even for a
|
||||
/// reversal dated before its run, and agrees with
|
||||
/// depreciation_effects summed through any month's end.
|
||||
/// 3. NEVER panics: a sum past exact arithmetic is refused
|
||||
/// (TooLargeToCompute).
|
||||
/// ```
|
||||
pub fn accumulated_posted(
|
||||
&self,
|
||||
id: AssetId,
|
||||
as_of: NaiveDate,
|
||||
) -> Result<Decimal, LedgerError> {
|
||||
self.accumulated_posted_with(id, as_of, &self.posted_reversals())
|
||||
}
|
||||
|
||||
/// [`Self::accumulated_posted`] for every registered asset, the
|
||||
/// reversal links read once (for the register and the viewer).
|
||||
pub fn accumulated_posted_all(
|
||||
&self,
|
||||
as_of: NaiveDate,
|
||||
) -> Result<std::collections::BTreeMap<AssetId, Decimal>, LedgerError> {
|
||||
let reversals = self.posted_reversals();
|
||||
self.assets
|
||||
.keys()
|
||||
.map(|&id| Ok((id, self.accumulated_posted_with(id, as_of, &reversals)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn accumulated_posted_with(
|
||||
&self,
|
||||
id: AssetId,
|
||||
as_of: NaiveDate,
|
||||
reversals: &HashMap<EntryId, EntryId>,
|
||||
) -> Result<Decimal, LedgerError> {
|
||||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||||
if matches!(asset.status, AssetStatus::Disposed { date, .. } if date <= as_of) {
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
self.carried_between(id, NaiveDate::MIN, as_of, reversals)
|
||||
}
|
||||
|
||||
/// Depreciation the books posted for an asset through entries dated in
|
||||
/// `[from, to]` (its runs and their reversals, as
|
||||
/// [`Self::accumulated_posted`] counts them, the disposal aside): the
|
||||
/// period's depreciation expense from its runs.
|
||||
pub fn depreciation_posted_between(
|
||||
&self,
|
||||
id: AssetId,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
) -> Result<Decimal, LedgerError> {
|
||||
if !self.assets.contains_key(&id) {
|
||||
return Err(LedgerError::UnknownAsset(id));
|
||||
}
|
||||
self.carried_between(id, from, to, &self.posted_reversals())
|
||||
}
|
||||
|
||||
/// What the entries of the asset's runs' chains dated in `[from, to]`
|
||||
/// carry, summed exactly.
|
||||
fn carried_between(
|
||||
&self,
|
||||
id: AssetId,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
reversals: &HashMap<EntryId, EntryId>,
|
||||
) -> Result<Decimal, LedgerError> {
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute(format!("asset {id}'s posted depreciation"));
|
||||
assets::run_chain_entries(self, &self.depreciation_run_entries(id), reversals)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| *d >= from && *d <= to)
|
||||
.try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(a))
|
||||
.ok_or_else(too_large)
|
||||
}
|
||||
|
||||
/// The run the books carry for an asset's month, if any: the first one
|
||||
/// recorded for `period` whose entry is in effect ([`Self::in_effect`]:
|
||||
/// posted, and an even number of posted reversals after it). A month
|
||||
/// whose run was reversed has none, so it can be run again; one whose
|
||||
/// reversal was itself reversed has its run back. At most one run of a
|
||||
/// month is in effect: a store refuses re-instating a run whose month
|
||||
/// was run again.
|
||||
pub fn live_depreciation_run(
|
||||
&self,
|
||||
id: AssetId,
|
||||
period: &str,
|
||||
) -> Option<&assets::DepreciationPosting> {
|
||||
self.depreciation_runs(id)
|
||||
.iter()
|
||||
.find(|run| run.period == period && self.in_effect(run.entry))
|
||||
}
|
||||
|
||||
/// Mark an asset disposed. The disposal *entry* is posted through the
|
||||
/// normal machinery by the caller; this records the lifecycle change.
|
||||
///
|
||||
@@ -2439,11 +2809,22 @@ impl Ledger {
|
||||
});
|
||||
}
|
||||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||||
// Activeness, proceeds sign, and date order; no depreciation is
|
||||
// computed, so a journaled disposal replays even where the figure
|
||||
// can't be (rules.md B-28).
|
||||
assets::check_disposal(asset, date, proceeds)?;
|
||||
// Activeness and the proceeds' sign; no depreciation is computed,
|
||||
// so a journaled disposal replays even where the figure can't be
|
||||
// (rules.md B-28).
|
||||
assets::check_disposal(asset, proceeds)?;
|
||||
if strict {
|
||||
// Never before the company acquired it. A command's bound, not
|
||||
// replay's: the acquired date needs the link, which a
|
||||
// projection read-back restores after the disposal (rules.md
|
||||
// B-28).
|
||||
let acquired = self.asset_acquired_date(id).expect("known asset");
|
||||
if date < acquired {
|
||||
return Err(LedgerError::DisposalBeforeAcquisition {
|
||||
date: date.to_string(),
|
||||
acquired: acquired.to_string(),
|
||||
});
|
||||
}
|
||||
// Proceeds are a line of the disposal entry, and its date one
|
||||
// the books keep (P0 review round 3, S1).
|
||||
if !kernel::Amount::in_line_range(proceeds.amount) {
|
||||
|
||||
@@ -44,6 +44,7 @@ pub mod schedules;
|
||||
pub mod settings;
|
||||
pub mod shares;
|
||||
pub mod tax;
|
||||
pub mod tax_capital;
|
||||
|
||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||
pub use arap::{
|
||||
@@ -51,10 +52,11 @@ pub use arap::{
|
||||
Vendor, VendorId,
|
||||
};
|
||||
pub use assets::{
|
||||
Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset,
|
||||
PostedRun, RunAmount, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation,
|
||||
book_value, catch_up_room, check_disposal, depreciation_effects, depreciation_schedule,
|
||||
disposal_amounts, life_end, run_amount, run_amounts,
|
||||
Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod,
|
||||
DepreciationPosting, DisposalAmounts, NewAsset, PostedRun, RunAmount, ScheduleLine,
|
||||
Sec704cMethod, accumulated_scheduled_through, catch_up_room, check_disposal,
|
||||
depreciation_effects, depreciation_schedule, disposal_amounts, life_end, run_amount,
|
||||
run_amounts, scheduled_book_value,
|
||||
};
|
||||
pub use cashflow::{
|
||||
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
|
||||
|
||||
@@ -1675,6 +1675,50 @@ fn true_up_reaches_the_target() {
|
||||
kani::cover!(down > 0, "depreciation past the day, credited back");
|
||||
}
|
||||
|
||||
/// `kernel::basis_figures` (rules.md B-25), for every rule and every
|
||||
/// cost, adjusted basis and value within the bounds: a purchase
|
||||
/// depreciates from cost with no §723 basis or built-in gain; a
|
||||
/// contribution's §723 basis is the adjusted basis and its built-in gain
|
||||
/// the value less it (negative exactly when the basis exceeds the value);
|
||||
/// carryover depreciates from the basis, personal use from the lesser of
|
||||
/// value and basis (at most each, equal to one); no overflow is reachable.
|
||||
#[kani::proof]
|
||||
fn basis_figures_laws() {
|
||||
use crate::assets::BasisRule;
|
||||
let r: u8 = kani::any();
|
||||
kani::assume(r < 3);
|
||||
let rule = match r {
|
||||
0 => BasisRule::Purchase,
|
||||
1 => BasisRule::Carryover723,
|
||||
_ => BasisRule::PersonalUseConversion,
|
||||
};
|
||||
let (cost, adjusted, fmv) = (any_amount(), any_amount(), any_amount());
|
||||
kani::assume(cost > 0 && adjusted >= 0 && fmv >= 0);
|
||||
let f = kernel::basis_figures(rule, cost, adjusted, fmv).expect("no overflow");
|
||||
match rule {
|
||||
BasisRule::Purchase => {
|
||||
assert_eq!(f.depreciable_basis, cost);
|
||||
assert!(f.tax_basis_723.is_none() && f.built_in_gain_704c.is_none());
|
||||
}
|
||||
_ => {
|
||||
assert_eq!(f.tax_basis_723, Some(adjusted));
|
||||
let g = f.built_in_gain_704c.expect("a contribution's gain");
|
||||
assert_eq!(g, fmv - adjusted);
|
||||
assert_eq!(g < 0, adjusted > fmv);
|
||||
if rule == BasisRule::Carryover723 {
|
||||
assert_eq!(f.depreciable_basis, adjusted);
|
||||
} else {
|
||||
assert!(f.depreciable_basis <= adjusted && f.depreciable_basis <= fmv);
|
||||
assert!(f.depreciable_basis == adjusted || f.depreciable_basis == fmv);
|
||||
}
|
||||
}
|
||||
}
|
||||
kani::cover!(
|
||||
rule == BasisRule::PersonalUseConversion && fmv < adjusted,
|
||||
"personal use worth less than its basis: a built-in loss"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The checker itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1166,30 +1166,53 @@ pub struct AssetRegisterRow {
|
||||
pub description: String,
|
||||
/// The manufacturer's serial number, if recorded.
|
||||
pub serial: Option<String>,
|
||||
/// Placed in service (depreciation starts).
|
||||
pub in_service: NaiveDate,
|
||||
/// The day the company acquired it: its acquisition entry's date, or
|
||||
/// the in-service date when none is linked
|
||||
/// (`Ledger::asset_acquired_date`). On the register from this day.
|
||||
pub acquired_date: NaiveDate,
|
||||
/// Placed in service: depreciation starts.
|
||||
pub in_service_date: NaiveDate,
|
||||
/// Capitalized cost.
|
||||
pub cost: Decimal,
|
||||
/// Salvage value.
|
||||
pub salvage: Decimal,
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// Accumulated depreciation by its schedule through the date (zero once
|
||||
/// disposed: it left the books with the asset).
|
||||
pub accumulated: Decimal,
|
||||
/// Cost less accumulated (zero once disposed).
|
||||
/// Accumulated depreciation its schedule says is due through the date
|
||||
/// (zero once disposed: it left the books with the asset).
|
||||
pub accumulated_scheduled: Decimal,
|
||||
/// Accumulated depreciation the books carry for it on the date: its
|
||||
/// runs posted, less what reversals took back (`Ledger::
|
||||
/// accumulated_posted`; zero once disposed).
|
||||
pub accumulated_posted: Decimal,
|
||||
/// What the books carry: cost less accumulated_posted (zero once
|
||||
/// disposed).
|
||||
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>,
|
||||
/// How the company got it (the basis rule), if recorded.
|
||||
pub basis_rule: Option<crate::BasisRule>,
|
||||
/// 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>,
|
||||
pub contributor_adjusted_basis: Option<Decimal>,
|
||||
/// Fair market value at contribution (a contribution only): recorded,
|
||||
/// or the book cost for a record from before P3 (`fmv_recorded`
|
||||
/// false).
|
||||
pub fmv_at_contribution: Option<Decimal>,
|
||||
/// Whether the value at contribution was recorded as a fact.
|
||||
pub fmv_recorded: bool,
|
||||
/// The §723 basis at contribution (Asset::basis_figures).
|
||||
pub tax_basis_723: Option<Decimal>,
|
||||
/// The depreciable tax basis at contribution or purchase.
|
||||
pub depreciable_basis: Option<Decimal>,
|
||||
/// The §704(c) built-in gain (+) or loss (−) at contribution.
|
||||
pub built_in_gain_704c: Option<Decimal>,
|
||||
/// A built-in loss: it counts only for the contributing member(s),
|
||||
/// IRC §704(c)(1)(C).
|
||||
pub sec704c_1c: bool,
|
||||
/// The §704(c) method chosen for it, if chosen.
|
||||
pub sec704c_method: Option<crate::Sec704cMethod>,
|
||||
/// Who gave its basis facts, when, and on what.
|
||||
pub basis_source: Option<crate::settings::Source>,
|
||||
}
|
||||
|
||||
/// The fixed asset register as of a date, reconciled to the general ledger.
|
||||
@@ -1197,20 +1220,24 @@ pub struct AssetRegisterRow {
|
||||
pub struct AssetRegister {
|
||||
/// The date.
|
||||
pub as_of: NaiveDate,
|
||||
/// Every asset registered by the date, in registration order; disposed
|
||||
/// Every asset acquired by the date, in registration order; disposed
|
||||
/// ones carry their disposal and no cost on the books.
|
||||
pub rows: Vec<AssetRegisterRow>,
|
||||
/// Total cost of the assets still held.
|
||||
pub total_cost: Decimal,
|
||||
/// Their accumulated depreciation by schedule.
|
||||
pub total_accumulated: Decimal,
|
||||
/// Their book value.
|
||||
pub total_accumulated_scheduled: Decimal,
|
||||
/// Their accumulated depreciation the books carry from runs.
|
||||
pub total_accumulated_posted: Decimal,
|
||||
/// Their book value: total cost less total_accumulated_posted.
|
||||
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,
|
||||
/// a basis recorded.
|
||||
pub total_depreciable_basis: Decimal,
|
||||
/// The total §723 basis of the contributed assets still held.
|
||||
pub total_tax_basis_723: Decimal,
|
||||
/// How many assets still held have no basis recorded.
|
||||
pub missing_basis: usize,
|
||||
/// The general ledger's balance (debit positive) of the assets' cost
|
||||
/// accounts on the date.
|
||||
pub ledger_cost: Decimal,
|
||||
@@ -1224,7 +1251,15 @@ impl AssetRegister {
|
||||
/// don't carry yet (run `run_depreciation`); negative if the books
|
||||
/// carry more.
|
||||
pub fn unposted_depreciation(&self) -> Decimal {
|
||||
self.total_accumulated - self.ledger_accumulated
|
||||
self.total_accumulated_scheduled - self.ledger_accumulated
|
||||
}
|
||||
|
||||
/// Accumulated depreciation in the accounts that no held asset's runs
|
||||
/// account for: zero when every depreciation in those accounts came
|
||||
/// from a run; otherwise an entry made by hand (or a run reversed
|
||||
/// after its asset left the books) is in them.
|
||||
pub fn unattributed_accumulated(&self) -> Decimal {
|
||||
self.ledger_accumulated - self.total_accumulated_posted
|
||||
}
|
||||
|
||||
/// Cost in the ledger's cost accounts that no held asset accounts for
|
||||
@@ -1235,31 +1270,106 @@ impl AssetRegister {
|
||||
}
|
||||
}
|
||||
|
||||
/// One asset's register row as of `as_of` (fixed_asset_register
|
||||
/// INVARIANTS 1, 2 and 5), or `None` before it was acquired: the one
|
||||
/// derivation the register and books.list_assets share. `posted` is
|
||||
/// `Ledger::accumulated_posted_all(as_of)`.
|
||||
pub fn register_row(
|
||||
ledger: &Ledger,
|
||||
a: &crate::Asset,
|
||||
as_of: NaiveDate,
|
||||
posted: &std::collections::BTreeMap<crate::AssetId, Decimal>,
|
||||
) -> Result<Option<AssetRegisterRow>, LedgerError> {
|
||||
use crate::AssetStatus;
|
||||
let acquired = ledger
|
||||
.asset_acquired_date(a.id)
|
||||
.unwrap_or(a.in_service_date);
|
||||
if acquired > as_of {
|
||||
return Ok(None);
|
||||
}
|
||||
let disposed = match &a.status {
|
||||
AssetStatus::Disposed { date, proceeds } if *date <= as_of => {
|
||||
Some((*date, proceeds.amount))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (sched, post) = if disposed.is_some() {
|
||||
(Decimal::ZERO, Decimal::ZERO)
|
||||
} else {
|
||||
(
|
||||
crate::assets::accumulated_scheduled_through(a, as_of)?,
|
||||
posted.get(&a.id).copied().unwrap_or(Decimal::ZERO),
|
||||
)
|
||||
};
|
||||
let figures = a.basis_figures()?;
|
||||
let book_value = if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
a.cost
|
||||
.amount
|
||||
.checked_sub(post)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s book value", a.id)))?
|
||||
};
|
||||
Ok(Some(AssetRegisterRow {
|
||||
asset_id: a.id,
|
||||
tag: a.tag.clone(),
|
||||
description: a.description.clone(),
|
||||
serial: a.serial.clone(),
|
||||
acquired_date: acquired,
|
||||
in_service_date: a.in_service_date,
|
||||
cost: a.cost.amount,
|
||||
salvage: a.salvage_value.amount,
|
||||
useful_life_months: a.useful_life_months,
|
||||
accumulated_scheduled: sched,
|
||||
accumulated_posted: post,
|
||||
book_value,
|
||||
disposed,
|
||||
basis_rule: a.basis.as_ref().map(|b| b.rule),
|
||||
contributor_adjusted_basis: a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis),
|
||||
fmv_at_contribution: a.fmv_at_contribution(),
|
||||
fmv_recorded: a.fmv_is_recorded(),
|
||||
tax_basis_723: figures.and_then(|f| f.tax_basis_723),
|
||||
depreciable_basis: figures.map(|f| f.depreciable_basis),
|
||||
built_in_gain_704c: figures.and_then(|f| f.built_in_gain_704c),
|
||||
sec704c_1c: figures
|
||||
.and_then(|f| f.built_in_gain_704c)
|
||||
.is_some_and(|g| g < Decimal::ZERO),
|
||||
sec704c_method: a.basis.as_ref().and_then(|b| b.sec704c_method),
|
||||
basis_source: a.basis.as_ref().and_then(|b| b.source.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The fixed asset register as of a date.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one list of everything the company owns as a fixed asset: what,
|
||||
/// which label, cost, depreciation, book value, and whether the
|
||||
/// books agree.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (depreciation); DECISION (owner,
|
||||
/// 2026-10-15): a fully featured asset register.
|
||||
/// which label, cost, depreciation due and posted, book value, tax
|
||||
/// figures, and whether the books agree.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (depreciation), B-24 (the register
|
||||
/// reconciles to the books), B-25 (tax figures beside book);
|
||||
/// DECISION (owner, 2026-10-15): a fully featured asset register.
|
||||
/// INVARIANTS:
|
||||
/// 1. Rows: the assets registered on or before as_of, in registration
|
||||
/// order; an asset disposed on or before as_of shows its disposal,
|
||||
/// zero accumulated and book value, and counts in no total.
|
||||
/// 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; 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.
|
||||
/// 1. Rows: the assets acquired on or before as_of
|
||||
/// (Ledger::asset_acquired_date), in registration order; an asset
|
||||
/// disposed on or before as_of shows its disposal, zero accumulated
|
||||
/// and book value, and counts in no total.
|
||||
/// 2. accumulated_scheduled is the asset's schedule through as_of
|
||||
/// (assets::accumulated_scheduled_through); accumulated_posted is
|
||||
/// what the books carry (Ledger::accumulated_posted); book_value =
|
||||
/// cost − accumulated_posted: the books' figure, never the
|
||||
/// schedule's.
|
||||
/// 3. Totals are the sums over the held assets, exactly; the
|
||||
/// depreciable basis total over those with a basis recorded (and
|
||||
/// missing_basis counts the rest); the §723 total over the
|
||||
/// contributions.
|
||||
/// 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
|
||||
/// unposted_depreciation and unregistered_cost are zero exactly when
|
||||
/// register and books agree.
|
||||
/// unregistered_cost, unposted_depreciation and
|
||||
/// unattributed_accumulated are zero exactly when register and books
|
||||
/// agree.
|
||||
/// 5. Each row's tax fields are the asset's (Asset::basis_figures),
|
||||
/// never re-derived here.
|
||||
/// 6. NEVER panics: a contributor basis is as journaled, of any size
|
||||
/// (rules.md B-28), and a total past exact arithmetic, or a schedule
|
||||
/// that can't be computed, is refused (TooLargeToCompute,
|
||||
@@ -1270,66 +1380,44 @@ pub fn fixed_asset_register(
|
||||
as_of: NaiveDate,
|
||||
) -> Result<AssetRegister, LedgerError> {
|
||||
let too_large = |what: &str| LedgerError::TooLargeToCompute(format!("the register's {what}"));
|
||||
use crate::AssetStatus;
|
||||
let posted = ledger.accumulated_posted_all(as_of)?;
|
||||
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, mut scheduled, mut posted_total) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
let (mut depreciable_total, mut total_723, mut missing) =
|
||||
(Decimal::ZERO, Decimal::ZERO, 0usize);
|
||||
let mut cost_accounts = std::collections::BTreeSet::new();
|
||||
let mut accum_accounts = std::collections::BTreeSet::new();
|
||||
for a in ledger
|
||||
.registered_assets()
|
||||
.filter(|a| a.purchase_date <= as_of)
|
||||
{
|
||||
for a in ledger.registered_assets() {
|
||||
let Some(row) = register_row(ledger, a, as_of, &posted)? else {
|
||||
continue;
|
||||
};
|
||||
cost_accounts.insert(a.cost_account);
|
||||
accum_accounts.insert(a.accum_depr_account);
|
||||
let disposed = match &a.status {
|
||||
AssetStatus::Disposed { date, proceeds } if *date <= as_of => {
|
||||
Some((*date, proceeds.amount))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let acc = if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
crate::assets::accumulated_depreciation(a, as_of)?
|
||||
};
|
||||
if disposed.is_none() {
|
||||
if row.disposed.is_none() {
|
||||
cost = cost
|
||||
.checked_add(a.cost.amount)
|
||||
.checked_add(row.cost)
|
||||
.ok_or_else(|| too_large("cost"))?;
|
||||
accumulated = accumulated
|
||||
.checked_add(acc)
|
||||
scheduled = scheduled
|
||||
.checked_add(row.accumulated_scheduled)
|
||||
.ok_or_else(|| too_large("accumulated depreciation"))?;
|
||||
match a.tax_basis() {
|
||||
Some(t) => {
|
||||
tax_total = tax_total
|
||||
.checked_add(t)
|
||||
.ok_or_else(|| too_large("tax basis"))?
|
||||
posted_total = posted_total
|
||||
.checked_add(row.accumulated_posted)
|
||||
.ok_or_else(|| too_large("posted depreciation"))?;
|
||||
match row.depreciable_basis {
|
||||
Some(b) => {
|
||||
depreciable_total = depreciable_total
|
||||
.checked_add(b)
|
||||
.ok_or_else(|| too_large("depreciable basis"))?;
|
||||
if let Some(b) = row.tax_basis_723 {
|
||||
total_723 = total_723
|
||||
.checked_add(b)
|
||||
.ok_or_else(|| too_large("§723 basis"))?;
|
||||
}
|
||||
}
|
||||
None => tax_missing += 1,
|
||||
None => missing += 1,
|
||||
}
|
||||
}
|
||||
rows.push(AssetRegisterRow {
|
||||
asset_id: a.id,
|
||||
tag: a.tag.clone(),
|
||||
description: a.description.clone(),
|
||||
serial: a.serial.clone(),
|
||||
in_service: a.purchase_date,
|
||||
cost: a.cost.amount,
|
||||
salvage: a.salvage_value.amount,
|
||||
useful_life_months: a.useful_life_months,
|
||||
accumulated: acc,
|
||||
book_value: if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
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(),
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
let tb = trial_balance_as_of(ledger, as_of);
|
||||
let net = |id: crate::AccountId| -> Decimal {
|
||||
@@ -1347,10 +1435,14 @@ pub fn fixed_asset_register(
|
||||
as_of,
|
||||
rows,
|
||||
total_cost: cost,
|
||||
total_accumulated: accumulated,
|
||||
total_book_value: cost - accumulated,
|
||||
total_tax_basis: tax_total,
|
||||
missing_tax_basis: tax_missing,
|
||||
total_accumulated_scheduled: scheduled,
|
||||
total_accumulated_posted: posted_total,
|
||||
total_book_value: cost
|
||||
.checked_sub(posted_total)
|
||||
.ok_or_else(|| too_large("book value"))?,
|
||||
total_depreciable_basis: depreciable_total,
|
||||
total_tax_basis_723: total_723,
|
||||
missing_basis: missing,
|
||||
ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(),
|
||||
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Tax-basis capital: each member's capital account as Schedule K-1 Item L
|
||||
//! reports it under the tax-basis method, beside the book figures of the
|
||||
//! K-1 worksheet ([`crate::k1`]), with the reconciliation between them.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: hand the CPA, per member, Item L on the tax basis as far as the
|
||||
//! books know it, every figure the books can't know named with what
|
||||
//! the books do have, and how book capital reconciles to it.
|
||||
//! SOURCE: Instructions for Form 1065 (2025), Item L, "Tax-basis method"
|
||||
//! (docs/spec/sources/irs-partnerships.md): capital contributed is
|
||||
//! "the amount of cash plus the adjusted tax basis of all property
|
||||
//! contributed"; net income is the partner's share "as figured for
|
||||
//! tax purposes"; distributions are "the amount of cash plus the
|
||||
//! adjusted tax basis of all property distributed"; required since
|
||||
//! tax year 2020 (Instructions for Form 1065 (2020): "you must use
|
||||
//! the tax basis method this year"). IRC §723 (the basis of
|
||||
//! contributed property); Treas. Reg. §1.704-3(a)(3)(ii) (built-in
|
||||
//! gain); DECISION D-41 (owner, 2026-10-20).
|
||||
//! INVARIANTS:
|
||||
//! 1. The book column is the K-1 worksheet's row (k1::k1_detail),
|
||||
//! unchanged.
|
||||
//! 2. Contributions: cash as on the book side; property at its §723
|
||||
//! basis. Within an entry, the property's built-in gain (its value at
|
||||
//! contribution less its §723 basis, Asset::basis_figures) and the
|
||||
//! book amount of property without a basis in the books are shared
|
||||
//! among the members as its book value is (by their property in it,
|
||||
//! to the cent, odd cents by largest remainder, ties to the earlier
|
||||
//! member), so a member's tax property = book property − their share
|
||||
//! of the built-in gain − their share of the property without a basis.
|
||||
//! 3. A figure the books can't know is never guessed: its line carries
|
||||
//! `known`, the part the books have, and `open`, each missing item
|
||||
//! with the book amount it replaces. A line with no open item is the
|
||||
//! tax figure.
|
||||
//! - Net income: tax depreciation of every fixed asset in service
|
||||
//! in the span (MACRS, and for contributed property the §704(c)
|
||||
//! method's allocation) is the CPA's, as is the tax gain on a
|
||||
//! disposal; `known` is the book share, the open items say so.
|
||||
//! - Property distributed: its adjusted tax basis is the CPA's.
|
||||
//! - A change in book capital the books can't explain ("other").
|
||||
//! - Beginning capital from earlier years whose tax figures were open.
|
||||
//! 4. Each row: ending.known = beginning.known + contributions_cash +
|
||||
//! contributions_property.known + net_income.known + other.known −
|
||||
//! distributions.known, exactly; ending.open is every open item of
|
||||
//! the row.
|
||||
//! 5. Reconciliation: book ending − tax ending.known = (book beginning −
|
||||
//! tax beginning.known) + built-in gain share + property without a
|
||||
//! basis (book) − property distributed (book), exactly.
|
||||
//! SCENARIOS:
|
||||
//! 1. IF two members jointly contribute a laptop worth 1,440.00 whose
|
||||
//! adjusted basis is 1,688.40, and nothing else happens:
|
||||
//! - SHOULD give each book property 720.00, tax property 844.20, a
|
||||
//! built-in gain share of −124.20, every line complete
|
||||
//! BECAUSE Item L counts property at its adjusted tax basis (§723).
|
||||
//! 2. IF a fixed asset is in service during the span:
|
||||
//! - SHOULD leave the tax net income open, with the book depreciation
|
||||
//! posted in the span
|
||||
//! BECAUSE tax depreciation is the CPA's (rules.md B-15, B-25).
|
||||
//! ```
|
||||
|
||||
use crate::k1::{K1Kind, K1Row, k1_detail};
|
||||
use crate::reports::MemberCapital;
|
||||
use crate::{AccountId, AssetId, AssetStatus, EntryId, Ledger, LedgerError, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// A figure the books can't know, with what they do have.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Open {
|
||||
/// Beginning capital: earlier years left a tax figure open, so last
|
||||
/// year's Item L ending (on the tax basis) is the CPA's return's.
|
||||
PriorYears,
|
||||
/// Property contributed in `entry` with no tax basis in the books (no
|
||||
/// registered asset linked to the entry holds a contribution basis for
|
||||
/// it): this member's share of its book amount, which the tax figure
|
||||
/// replaces with the property's adjusted basis.
|
||||
PropertyBasis {
|
||||
/// The contribution entry.
|
||||
entry: EntryId,
|
||||
/// This member's share of that property at book.
|
||||
book: Decimal,
|
||||
},
|
||||
/// The assets linked to `entry` are valued at more than the property
|
||||
/// the entry contributed: the books disagree with themselves, so none
|
||||
/// of its property's tax basis is taken from them.
|
||||
LinkedValueExceedsEntry {
|
||||
/// The contribution entry.
|
||||
entry: EntryId,
|
||||
/// The linked assets' values at contribution.
|
||||
linked: Decimal,
|
||||
/// The property the entry debits.
|
||||
property: Decimal,
|
||||
},
|
||||
/// Property distributed in `entry`: this member's share of its book
|
||||
/// amount, which the tax figure replaces with the company's adjusted
|
||||
/// tax basis in it.
|
||||
PropertyDistribution {
|
||||
/// The distribution entry.
|
||||
entry: EntryId,
|
||||
/// This member's share of that property at book.
|
||||
book: Decimal,
|
||||
},
|
||||
/// Tax depreciation of the fixed assets in service in the span (MACRS,
|
||||
/// IRC §168; for contributed property allocated by its §704(c) method,
|
||||
/// Treas. Reg. §1.704-3): the CPA's. The book depreciation posted in
|
||||
/// the span, all members, and the part on contributed property.
|
||||
TaxDepreciation {
|
||||
/// Book depreciation posted in the span from runs.
|
||||
book: Decimal,
|
||||
/// The part of it on contributed property.
|
||||
contributed_book: Decimal,
|
||||
},
|
||||
/// A fixed asset disposed in the span: its tax gain or loss (from its
|
||||
/// adjusted tax basis) is the CPA's.
|
||||
Disposal {
|
||||
/// The asset.
|
||||
asset: AssetId,
|
||||
/// The proceeds.
|
||||
proceeds: Decimal,
|
||||
},
|
||||
/// A change in book capital the books can't explain (the statement of
|
||||
/// members' equity's "other"): its tax treatment is the CPA's.
|
||||
OtherChange {
|
||||
/// The book amount.
|
||||
book: Decimal,
|
||||
},
|
||||
}
|
||||
|
||||
/// One Item L line on the tax basis ([`Open`]: INVARIANT 3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TaxLine {
|
||||
/// The figure as far as the books know it.
|
||||
pub known: Decimal,
|
||||
/// What the CPA must still settle; empty when `known` is the tax
|
||||
/// figure.
|
||||
pub open: Vec<Open>,
|
||||
}
|
||||
|
||||
impl TaxLine {
|
||||
fn complete(known: Decimal) -> Self {
|
||||
Self {
|
||||
known,
|
||||
open: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tax figure, when the books know all of it.
|
||||
pub fn tax(&self) -> Option<Decimal> {
|
||||
self.open.is_empty().then_some(self.known)
|
||||
}
|
||||
}
|
||||
|
||||
/// Book capital less tax-basis capital, by cause (INVARIANT 5).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Reconciliation {
|
||||
/// Beginning capital, book less tax (as far as known).
|
||||
pub beginning: Decimal,
|
||||
/// This member's share of the §704(c) built-in gain (+) or loss (−) at
|
||||
/// contribution of the property they contributed in the span.
|
||||
pub built_in_gain_704c: Decimal,
|
||||
/// Their share of property contributed with no tax basis in the books,
|
||||
/// at book.
|
||||
pub property_without_basis: Decimal,
|
||||
/// Their share of property distributed, at book (it lowers book
|
||||
/// capital by its book amount, tax capital by its basis).
|
||||
pub property_distributed: Decimal,
|
||||
}
|
||||
|
||||
impl Reconciliation {
|
||||
/// Book ending less tax ending (as far as known).
|
||||
pub fn total(&self) -> Option<Decimal> {
|
||||
self.beginning
|
||||
.checked_add(self.built_in_gain_704c)?
|
||||
.checked_add(self.property_without_basis)?
|
||||
.checked_sub(self.property_distributed)
|
||||
}
|
||||
}
|
||||
|
||||
/// One member's capital, book beside tax basis.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TaxCapitalRow {
|
||||
/// The K-1 worksheet's book row (INVARIANT 1).
|
||||
pub book: K1Row,
|
||||
/// Beginning capital on the tax basis.
|
||||
pub beginning: TaxLine,
|
||||
/// Cash contributed (the same on both bases).
|
||||
pub contributions_cash: Decimal,
|
||||
/// Property contributed, at its adjusted tax basis (§723).
|
||||
pub contributions_property: TaxLine,
|
||||
/// Share of net income (loss) as figured for tax purposes.
|
||||
pub net_income: TaxLine,
|
||||
/// Other increase (decrease).
|
||||
pub other: TaxLine,
|
||||
/// Withdrawals and distributions: cash plus the adjusted tax basis of
|
||||
/// property distributed.
|
||||
pub distributions: TaxLine,
|
||||
/// Ending capital on the tax basis (INVARIANT 4).
|
||||
pub ending: TaxLine,
|
||||
/// Book less tax, by cause (INVARIANT 5).
|
||||
pub reconciliation: Reconciliation,
|
||||
}
|
||||
|
||||
/// The tax-basis capital worksheet for a span.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TaxCapital {
|
||||
/// One row per member, in order.
|
||||
pub rows: Vec<TaxCapitalRow>,
|
||||
/// The items open for every member (tax depreciation, disposals).
|
||||
pub open: Vec<Open>,
|
||||
}
|
||||
|
||||
/// Tax-basis capital for `start..=end` (INVARIANTS 1-5). `cash` are the
|
||||
/// company's cash accounts, as for [`crate::k1::k1_worksheet`].
|
||||
pub fn tax_basis_capital(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
members: &[MemberCapital],
|
||||
cash: &[AccountId],
|
||||
) -> Result<TaxCapital, LedgerError> {
|
||||
// Beginning: the same worksheet from the books' first entry to the day
|
||||
// before, whose ending is this span's beginning.
|
||||
let first = ledger.posted_entries().map(|e| e.date()).min();
|
||||
let prior = match (first, start.pred_opt()) {
|
||||
(Some(first), Some(before)) if first <= before => {
|
||||
Some(span(ledger, first, before, members, cash, None)?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
span(ledger, start, end, members, cash, prior.as_ref())
|
||||
}
|
||||
|
||||
fn too_large() -> LedgerError {
|
||||
LedgerError::TooLargeToCompute("a member's tax-basis capital".into())
|
||||
}
|
||||
|
||||
/// `whole` shared in proportion to `weights` (non-negative), to the cent:
|
||||
/// each part rounded toward zero, leftover cents by largest remainder,
|
||||
/// ties to the earlier part; a negative whole is shared as its magnitude
|
||||
/// and negated. All zero when the weights sum to zero.
|
||||
fn share(whole: Decimal, weights: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
let total = weights
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, w| t.checked_add(*w))
|
||||
.ok_or_else(too_large)?;
|
||||
if whole.is_zero() || total <= Decimal::ZERO {
|
||||
return Ok(vec![Decimal::ZERO; weights.len()]);
|
||||
}
|
||||
let magnitude = whole.abs();
|
||||
let exact: Vec<Decimal> = weights
|
||||
.iter()
|
||||
.map(|w| magnitude.checked_mul(*w).and_then(|x| x.checked_div(total)))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(too_large)?;
|
||||
let mut parts: Vec<Decimal> = exact
|
||||
.iter()
|
||||
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||
.collect();
|
||||
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
|
||||
crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2))
|
||||
.ok_or_else(too_large)?;
|
||||
if whole < Decimal::ZERO {
|
||||
for p in &mut parts {
|
||||
*p = -*p;
|
||||
}
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
/// What one contribution entry's property is on the tax basis.
|
||||
struct EntryProperty {
|
||||
/// Σ built-in gain of the linked assets with a contribution basis.
|
||||
built_in_gain: Decimal,
|
||||
/// The property's book amount with no basis in the books.
|
||||
without_basis: Decimal,
|
||||
/// The linked assets' values exceed the entry's property (linked,
|
||||
/// property).
|
||||
mismatch: Option<(Decimal, Decimal)>,
|
||||
}
|
||||
|
||||
fn entry_property(
|
||||
ledger: &Ledger,
|
||||
entry: &crate::JournalEntry,
|
||||
cash: &[AccountId],
|
||||
) -> Result<EntryProperty, LedgerError> {
|
||||
let pool = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.side == Side::Debit && crate::k1::is_property_account(ledger, cash, l.account)
|
||||
})
|
||||
.try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount))
|
||||
.ok_or_else(too_large)?;
|
||||
let (mut linked, mut big, mut unknown) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
for a in ledger
|
||||
.registered_assets()
|
||||
.filter(|a| a.acquired_by == Some(entry.id()))
|
||||
{
|
||||
let figures = a.basis_figures()?;
|
||||
let contributed = figures.and_then(|f| f.built_in_gain_704c);
|
||||
// Its book amount in the entry: the value at contribution, which
|
||||
// the books recorded (D-41), or the book cost.
|
||||
let value = a.fmv_at_contribution().unwrap_or(a.cost.amount);
|
||||
linked = linked.checked_add(value).ok_or_else(too_large)?;
|
||||
match contributed {
|
||||
Some(g) => big = big.checked_add(g).ok_or_else(too_large)?,
|
||||
// No basis, or a purchase's basis on a contribution: unknown.
|
||||
None => unknown = unknown.checked_add(value).ok_or_else(too_large)?,
|
||||
}
|
||||
}
|
||||
if linked > pool {
|
||||
return Ok(EntryProperty {
|
||||
built_in_gain: Decimal::ZERO,
|
||||
without_basis: pool,
|
||||
mismatch: Some((linked, pool)),
|
||||
});
|
||||
}
|
||||
let rest = pool - linked;
|
||||
Ok(EntryProperty {
|
||||
built_in_gain: big,
|
||||
without_basis: unknown.checked_add(rest).ok_or_else(too_large)?,
|
||||
mismatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn span(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
members: &[MemberCapital],
|
||||
cash: &[AccountId],
|
||||
prior: Option<&TaxCapital>,
|
||||
) -> Result<TaxCapital, LedgerError> {
|
||||
let detail = k1_detail(ledger, start, end, members, cash)?;
|
||||
let n = members.len();
|
||||
let mut big = vec![Decimal::ZERO; n];
|
||||
let mut without = vec![Decimal::ZERO; n];
|
||||
let mut distributed = vec![Decimal::ZERO; n];
|
||||
let mut prop_open: Vec<Vec<Open>> = vec![Vec::new(); n];
|
||||
let mut dist_open: Vec<Vec<Open>> = vec![Vec::new(); n];
|
||||
// Entries in journal order, each once.
|
||||
let mut entries: Vec<EntryId> = Vec::new();
|
||||
for l in &detail.lines {
|
||||
if !entries.contains(&l.entry) {
|
||||
entries.push(l.entry);
|
||||
}
|
||||
}
|
||||
for id in entries {
|
||||
let e = ledger.get_entry(id).ok_or(LedgerError::UnknownEntry(id))?;
|
||||
let lines = |kind| -> Vec<Decimal> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
detail
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|l| l.entry == id && l.member == i && l.kind == kind)
|
||||
.map(|l| l.amount)
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let takes = lines(K1Kind::PropertyContribution);
|
||||
if takes.iter().any(|t| !t.is_zero()) {
|
||||
let p = entry_property(ledger, e, cash)?;
|
||||
let g = share(p.built_in_gain, &takes)?;
|
||||
let w = share(p.without_basis, &takes)?;
|
||||
for i in (0..n).filter(|&i| !takes[i].is_zero()) {
|
||||
big[i] = big[i].checked_add(g[i]).ok_or_else(too_large)?;
|
||||
without[i] = without[i].checked_add(w[i]).ok_or_else(too_large)?;
|
||||
if let Some((linked, property)) = p.mismatch {
|
||||
prop_open[i].push(Open::LinkedValueExceedsEntry {
|
||||
entry: id,
|
||||
linked,
|
||||
property,
|
||||
});
|
||||
}
|
||||
if !w[i].is_zero() {
|
||||
prop_open[i].push(Open::PropertyBasis {
|
||||
entry: id,
|
||||
book: w[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Distributions are negative lines; weights are what each took.
|
||||
let taken: Vec<Decimal> = lines(K1Kind::Distribution)
|
||||
.into_iter()
|
||||
.map(|d| -d)
|
||||
.collect();
|
||||
if taken.iter().any(|t| *t > Decimal::ZERO) {
|
||||
let property = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.side == Side::Credit
|
||||
&& crate::k1::is_property_account(ledger, cash, l.account)
|
||||
})
|
||||
.try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount))
|
||||
.ok_or_else(too_large)?;
|
||||
let total_taken = taken
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, x| t.checked_add(*x))
|
||||
.ok_or_else(too_large)?;
|
||||
let property = property.min(total_taken);
|
||||
if property > Decimal::ZERO {
|
||||
let parts = share(property, &taken)?;
|
||||
for i in (0..n).filter(|&i| !parts[i].is_zero()) {
|
||||
distributed[i] = distributed[i].checked_add(parts[i]).ok_or_else(too_large)?;
|
||||
dist_open[i].push(Open::PropertyDistribution {
|
||||
entry: id,
|
||||
book: parts[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Worksheet-level open items: tax depreciation while a fixed asset is
|
||||
// in service in the span; each disposal in it.
|
||||
let mut open = Vec::new();
|
||||
let (mut book_depr, mut contributed_depr) = (Decimal::ZERO, Decimal::ZERO);
|
||||
let mut in_service = false;
|
||||
for a in ledger.registered_assets() {
|
||||
let gone_before = matches!(a.status, AssetStatus::Disposed { date, .. } if date < start);
|
||||
if a.in_service_date <= end && !gone_before {
|
||||
in_service = true;
|
||||
}
|
||||
let d = ledger.depreciation_posted_between(a.id, start, end)?;
|
||||
book_depr = book_depr.checked_add(d).ok_or_else(too_large)?;
|
||||
if a.basis.as_ref().is_some_and(|b| b.rule.is_contribution()) {
|
||||
contributed_depr = contributed_depr.checked_add(d).ok_or_else(too_large)?;
|
||||
}
|
||||
}
|
||||
if in_service || !book_depr.is_zero() {
|
||||
open.push(Open::TaxDepreciation {
|
||||
book: book_depr,
|
||||
contributed_book: contributed_depr,
|
||||
});
|
||||
}
|
||||
for a in ledger.registered_assets() {
|
||||
if let AssetStatus::Disposed { date, proceeds } = &a.status
|
||||
&& *date >= start
|
||||
&& *date <= end
|
||||
{
|
||||
open.push(Open::Disposal {
|
||||
asset: a.id,
|
||||
proceeds: proceeds.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut rows = Vec::with_capacity(n);
|
||||
for (i, book) in detail.rows.into_iter().enumerate() {
|
||||
let beginning = match prior.and_then(|p| p.rows.get(i)) {
|
||||
Some(p) => TaxLine {
|
||||
known: p.ending.known,
|
||||
open: if p.ending.open.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Open::PriorYears]
|
||||
},
|
||||
},
|
||||
None => TaxLine::complete(book.beginning),
|
||||
};
|
||||
let contributions_property = TaxLine {
|
||||
known: book
|
||||
.contributions_property
|
||||
.checked_sub(big[i])
|
||||
.and_then(|x| x.checked_sub(without[i]))
|
||||
.ok_or_else(too_large)?,
|
||||
open: std::mem::take(&mut prop_open[i]),
|
||||
};
|
||||
let net_income = TaxLine {
|
||||
known: book.net_income,
|
||||
open: open.clone(),
|
||||
};
|
||||
let other = TaxLine {
|
||||
known: book.other,
|
||||
open: if book.other.is_zero() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Open::OtherChange { book: book.other }]
|
||||
},
|
||||
};
|
||||
let distributions = TaxLine {
|
||||
known: book
|
||||
.distributions
|
||||
.checked_sub(distributed[i])
|
||||
.ok_or_else(too_large)?,
|
||||
open: std::mem::take(&mut dist_open[i]),
|
||||
};
|
||||
let known = beginning
|
||||
.known
|
||||
.checked_add(book.contributions_cash)
|
||||
.and_then(|x| x.checked_add(contributions_property.known))
|
||||
.and_then(|x| x.checked_add(net_income.known))
|
||||
.and_then(|x| x.checked_add(other.known))
|
||||
.and_then(|x| x.checked_sub(distributions.known))
|
||||
.ok_or_else(too_large)?;
|
||||
let ending = TaxLine {
|
||||
known,
|
||||
open: [
|
||||
&beginning,
|
||||
&contributions_property,
|
||||
&net_income,
|
||||
&other,
|
||||
&distributions,
|
||||
]
|
||||
.iter()
|
||||
.flat_map(|l| l.open.iter().cloned())
|
||||
.collect(),
|
||||
};
|
||||
let reconciliation = Reconciliation {
|
||||
beginning: book
|
||||
.beginning
|
||||
.checked_sub(beginning.known)
|
||||
.ok_or_else(too_large)?,
|
||||
built_in_gain_704c: big[i],
|
||||
property_without_basis: without[i],
|
||||
property_distributed: distributed[i],
|
||||
};
|
||||
rows.push(TaxCapitalRow {
|
||||
contributions_cash: book.contributions_cash,
|
||||
book,
|
||||
beginning,
|
||||
contributions_property,
|
||||
net_income,
|
||||
other,
|
||||
distributions,
|
||||
ending,
|
||||
reconciliation,
|
||||
});
|
||||
}
|
||||
Ok(TaxCapital { rows, open })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
/// share: proportional, to the cent, largest remainder ties to the
|
||||
/// first; negative wholes negated; zero weights give zeros.
|
||||
#[test]
|
||||
fn share_is_exact_and_signed() {
|
||||
assert_eq!(
|
||||
share(d("-1021.37"), &[d("525"), d("525")]).unwrap(),
|
||||
vec![d("-510.69"), d("-510.68")]
|
||||
);
|
||||
assert_eq!(
|
||||
share(d("25"), &[d("525"), d("525")]).unwrap(),
|
||||
vec![d("12.50"), d("12.50")]
|
||||
);
|
||||
assert_eq!(
|
||||
share(d("10"), &[d("0"), d("0")]).unwrap(),
|
||||
vec![d("0"), d("0")]
|
||||
);
|
||||
assert_eq!(
|
||||
share(d("0.01"), &[d("1"), d("2")]).unwrap(),
|
||||
vec![d("0.00"), d("0.01")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset,
|
||||
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
|
||||
accumulated_scheduled_through, depreciation_schedule, disposal_amounts, scheduled_book_value,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
@@ -53,7 +53,7 @@ fn van_asset(
|
||||
ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Sprinter van".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
in_service_date: date(2026, 1, 15),
|
||||
cost: usd(&dollars.to_string()),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 60,
|
||||
@@ -112,7 +112,7 @@ fn salvage_value_is_never_depreciated() {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Machine".to_string(),
|
||||
purchase_date: date(2026, 3, 1),
|
||||
in_service_date: date(2026, 3, 1),
|
||||
cost: usd("50000"),
|
||||
salvage_value: usd("5000"),
|
||||
useful_life_months: 36,
|
||||
@@ -133,11 +133,11 @@ fn book_value_tracks_accumulated() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let acc = accumulated_depreciation(asset, date(2026, 12, 31)).unwrap();
|
||||
let acc = accumulated_scheduled_through(asset, date(2026, 12, 31)).unwrap();
|
||||
// Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000.
|
||||
assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
|
||||
assert_eq!(
|
||||
book_value(asset, date(2026, 12, 31)).unwrap(),
|
||||
scheduled_book_value(asset, date(2026, 12, 31)).unwrap(),
|
||||
Decimal::from(60_000) - acc
|
||||
);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ fn registration_validation() {
|
||||
.unwrap();
|
||||
let base = NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
@@ -222,7 +222,7 @@ fn registration_rejects_date_overflow() {
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Time machine".to_string(),
|
||||
purchase_date: NaiveDate::MAX,
|
||||
in_service_date: NaiveDate::MAX,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
@@ -240,7 +240,7 @@ fn registration_rejects_date_overflow() {
|
||||
// could still reach (P0 review round 3, S1).
|
||||
let new = |date: NaiveDate, months: u32| NewAsset {
|
||||
description: "Late machine".to_string(),
|
||||
purchase_date: date,
|
||||
in_service_date: date,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: months,
|
||||
@@ -270,7 +270,7 @@ fn schedule_through_purchase_date_covers_one_day() {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
in_service_date: date(2026, 1, 15),
|
||||
cost: usd("62000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 62,
|
||||
@@ -335,7 +335,7 @@ fn registration_rejects_wrong_typed_accounts() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let base = || NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
in_service_date: date(2026, 1, 15),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
@@ -415,7 +415,7 @@ fn one_month_useful_life_is_valid() {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Cheap tool".to_string(),
|
||||
purchase_date: date(2026, 3, 10),
|
||||
in_service_date: date(2026, 3, 10),
|
||||
cost: usd("300"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 1,
|
||||
@@ -445,11 +445,12 @@ fn disposal_on_the_purchase_date_is_allowed() {
|
||||
assert_eq!(d.accumulated, Decimal::new(3226, 2));
|
||||
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
|
||||
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
|
||||
// The day before purchase is not a disposal.
|
||||
assert!(matches!(
|
||||
disposal_amounts(&asset, date(2026, 1, 14), usd("60000")),
|
||||
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
// The day before it is in service nothing has accrued (whether the
|
||||
// company held it by then is its acquisition entry's: the ledger
|
||||
// checks that floor, Ledger::dispose_asset).
|
||||
let d = disposal_amounts(&asset, date(2026, 1, 14), usd("60000")).unwrap();
|
||||
assert_eq!(d.accumulated, Decimal::ZERO);
|
||||
assert_eq!(d.book_value, asset.cost.amount);
|
||||
}
|
||||
|
||||
/// An asset links to the posted entry that acquired it, if that entry
|
||||
|
||||
@@ -11,24 +11,27 @@
|
||||
//! due: a depreciation run, a disposal, a schedule run or registration),
|
||||
//! each accepted or refused as it says, with the parties, payee and
|
||||
//! payment method it is tagged with, and every change that posts no entry
|
||||
//! (a rename, a policy). The books are the engine's own store
|
||||
//! (`ledger_store::Store`, in a directory of their own), so every event
|
||||
//! goes through the command the books take it by, journaled, and the
|
||||
//! books must replay to what was kept (`Store::verify`). The queries the
|
||||
//! (a rename, a policy, an asset's correction). The books are the
|
||||
//! engine's own store (`ledger_store::Store`, in a directory of their
|
||||
//! own), so every event goes through the command the books take it by,
|
||||
//! journaled, and the books must replay to what was kept
|
||||
//! (`Store::verify`). The queries the
|
||||
//! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
|
||||
//! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`,
|
||||
//! `members_equity`, `k1`, `party_part`, `party_totals`,
|
||||
//! `party_tin_last4`, `period`, `day`, `calendar_year`, `account_names`,
|
||||
//! `uncategorized_accounts`, `run`, `disposal`, `depreciation_schedule`,
|
||||
//! `schedule_plan`) are then checked value by value, and the commands it
|
||||
//! tries
|
||||
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
|
||||
//! refused on a copy of the books, each after the event it names (`after`)
|
||||
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
|
||||
//! checked by ledger-package (`the_bookspec_iif_example_matches_the_engine`),
|
||||
//! and a query no crate answers is refused. A key the shape doesn't have
|
||||
//! is refused too, never ignored.
|
||||
//! `members_equity`, `k1`, `k1_tax_basis`, `k1_reconciliation`,
|
||||
//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`,
|
||||
//! `calendar_year`, `account_names`, `uncategorized_accounts`, `run`,
|
||||
//! `disposal`, `depreciation_schedule`, `schedule_plan`, `register_row`,
|
||||
//! `register_totals`, `asset_basis`) are then checked value by value, and
|
||||
//! the commands it tries (`rename_account`, `add_account`,
|
||||
//! `set_tax_category`) accepted or refused on a copy of the books, each
|
||||
//! after the event it names (`after`) or the last; the formats appendix's
|
||||
//! (`iif_lines`, `iif_changes`) are checked by ledger-package
|
||||
//! (`the_bookspec_iif_example_matches_the_engine`), and a query no crate
|
||||
//! answers is refused. A key the shape doesn't have is refused too, never
|
||||
//! ignored.
|
||||
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -36,6 +39,7 @@ use chrono::NaiveDate;
|
||||
use ledger_core::calendar::{PeriodDefaults, period_defaults};
|
||||
use ledger_core::parties::{Basis, MoneyAccounts};
|
||||
use ledger_core::settings::{Company, Source};
|
||||
use ledger_core::tax_capital::TaxCapitalRow;
|
||||
use ledger_core::{
|
||||
AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
|
||||
Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099,
|
||||
@@ -170,6 +174,32 @@ struct AssetSpec {
|
||||
cost_account: String,
|
||||
accumulated_account: String,
|
||||
expense_account: String,
|
||||
/// The event whose entry put it on the books (its purchase or a
|
||||
/// member's contribution), linked once that event is applied: its date
|
||||
/// is the asset's acquired date (rules.md B-15 rule 7).
|
||||
#[serde(default)]
|
||||
acquired_by: Option<usize>,
|
||||
/// How the company got it and the facts its tax figures follow from
|
||||
/// (rules.md B-25), recorded as it is registered.
|
||||
#[serde(default)]
|
||||
basis: Option<BasisSpec>,
|
||||
}
|
||||
|
||||
/// An asset's basis facts (rules.md B-25): `rule` `"purchase"`,
|
||||
/// `"carryover_723"` or `"personal_use_conversion"`; a contribution's
|
||||
/// `contributor_adjusted_basis` and `fmv_at_contribution`; its §704(c)
|
||||
/// method, if chosen (`"traditional"`, `"traditional_with_curative"`,
|
||||
/// `"remedial"`).
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BasisSpec {
|
||||
rule: String,
|
||||
#[serde(default)]
|
||||
contributor_adjusted_basis: Option<String>,
|
||||
#[serde(default)]
|
||||
fmv_at_contribution: Option<String>,
|
||||
#[serde(default)]
|
||||
sec704c_method: Option<String>,
|
||||
}
|
||||
|
||||
/// A prepaid expense or deferred revenue recognized over `months` from
|
||||
@@ -376,8 +406,9 @@ struct BillSpec {
|
||||
|
||||
/// A change to the books that posts no entry, made right after event
|
||||
/// `after` (counting from 1) once the expectations asked after that event
|
||||
/// are checked: a rename (`rename`) or the tax-category policy
|
||||
/// (`require_tax_category`), exactly one of them.
|
||||
/// are checked: a rename (`rename`), the tax-category policy
|
||||
/// (`require_tax_category`) or an asset's correction (`correct_asset`),
|
||||
/// exactly one of them.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ChangeSpec {
|
||||
@@ -388,6 +419,25 @@ struct ChangeSpec {
|
||||
/// "refuse" (rules.md B-32).
|
||||
#[serde(default)]
|
||||
require_tax_category: Option<String>,
|
||||
#[serde(default)]
|
||||
correct_asset: Option<CorrectSpec>,
|
||||
}
|
||||
|
||||
/// A registered asset's economics corrected (a registration mistake), for
|
||||
/// `reason`: each figure given replaces the asset's; the rest stay.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CorrectSpec {
|
||||
asset: String,
|
||||
#[serde(default)]
|
||||
cost: Option<String>,
|
||||
#[serde(default)]
|
||||
salvage: Option<String>,
|
||||
#[serde(default)]
|
||||
life_months: Option<u32>,
|
||||
#[serde(default)]
|
||||
in_service: Option<String>,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// Rename the account `code` to `name` (rules.md B-31).
|
||||
@@ -431,8 +481,9 @@ struct Expect {
|
||||
entry: Option<usize>,
|
||||
/// The `run_depreciation` event a `run` query asks about.
|
||||
event: Option<usize>,
|
||||
/// The fixed asset (`setup.assets` id) a `disposal` or
|
||||
/// `depreciation_schedule` query asks about.
|
||||
/// The fixed asset (`setup.assets` id) a `disposal`,
|
||||
/// `depreciation_schedule`, `register_row` or `asset_basis` query asks
|
||||
/// about.
|
||||
asset: Option<String>,
|
||||
/// The schedule (its id) a `schedule_plan` query asks about.
|
||||
schedule: Option<String>,
|
||||
@@ -792,6 +843,22 @@ fn add_parties(ex: &Example, store: &mut Store) -> BTreeMap<String, PartyId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// An asset's basis facts as the books record them.
|
||||
fn basis(b: &BasisSpec, what: &str) -> ledger_core::AssetBasis {
|
||||
let amount = |s: &Option<String>| s.as_deref().map(|v| exact(v, what));
|
||||
ledger_core::AssetBasis {
|
||||
rule: ledger_core::BasisRule::parse(&b.rule)
|
||||
.unwrap_or_else(|| panic!("{what}: basis rule {:?}", b.rule)),
|
||||
contributor_adjusted_basis: amount(&b.contributor_adjusted_basis),
|
||||
fmv_at_contribution: amount(&b.fmv_at_contribution),
|
||||
sec704c_method: b.sec704c_method.as_deref().map(|m| {
|
||||
ledger_core::Sec704cMethod::parse(m)
|
||||
.unwrap_or_else(|| panic!("{what}: §704(c) method {m:?}"))
|
||||
}),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A schedule as the books register it.
|
||||
fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule {
|
||||
let account = |code: &str| {
|
||||
@@ -892,7 +959,7 @@ impl<'a> Books<'a> {
|
||||
for fa in &s.fixed_assets {
|
||||
let new = NewAsset {
|
||||
description: format!("Fixed asset on {}", fa.cost_account),
|
||||
purchase_date: first,
|
||||
in_service_date: first,
|
||||
cost: Money::new(Decimal::ONE, currency),
|
||||
salvage_value: Money::new(Decimal::ZERO, currency),
|
||||
useful_life_months: 12,
|
||||
@@ -911,7 +978,7 @@ impl<'a> Books<'a> {
|
||||
assert_eq!(a.method, "straight_line", "{what}: method");
|
||||
let new = NewAsset {
|
||||
description: a.description.clone(),
|
||||
purchase_date: date(&a.in_service, &what),
|
||||
in_service_date: date(&a.in_service, &what),
|
||||
cost: Money::new(exact(&a.cost, &what), currency),
|
||||
salvage_value: Money::new(exact(&a.salvage, &what), currency),
|
||||
useful_life_months: a.life_months,
|
||||
@@ -924,6 +991,15 @@ impl<'a> Books<'a> {
|
||||
assets.insert(a.id.clone(), asset).is_none(),
|
||||
"{what}: id used twice"
|
||||
);
|
||||
if let Some(b) = &a.basis {
|
||||
took(store.set_asset_basis(asset, basis(b, &what)), &what);
|
||||
}
|
||||
assert!(
|
||||
a.acquired_by.is_none_or(|n| n >= 1 && n <= ex.events.len()),
|
||||
"{what}: acquired_by = {:?}: events count from 1, to {}",
|
||||
a.acquired_by,
|
||||
ex.events.len()
|
||||
);
|
||||
}
|
||||
let mut schedules = BTreeMap::new();
|
||||
for sc in &s.schedules {
|
||||
@@ -1190,6 +1266,17 @@ impl<'a> Books<'a> {
|
||||
assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry");
|
||||
}
|
||||
self.posted.push(posted);
|
||||
// The assets this event's entry put on the books.
|
||||
for a in spec
|
||||
.setup
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|a| a.acquired_by == Some(n))
|
||||
{
|
||||
let entry = self.entry_of(n, &format!("{what}: asset {}'s acquired_by", a.id));
|
||||
let asset = self.asset(&a.id, &what);
|
||||
took(self.store.set_asset_acquisition(asset, entry), &what);
|
||||
}
|
||||
if !e.parties.is_empty() {
|
||||
let entry = self.entry_of(n, &what);
|
||||
let shares: Vec<PartyShare> = e
|
||||
@@ -1391,12 +1478,30 @@ impl Books<'_> {
|
||||
let ex = &self.ex.id;
|
||||
let after = c.after;
|
||||
let what = format!("{ex} change after {after}");
|
||||
match (&c.rename, &c.require_tax_category) {
|
||||
(Some(r), None) => {
|
||||
match (&c.rename, &c.require_tax_category, &c.correct_asset) {
|
||||
(Some(r), None, None) => {
|
||||
let account = self.id(&r.code);
|
||||
took(self.store.rename_account(account, &r.name, None), &what);
|
||||
}
|
||||
(None, Some(policy)) => {
|
||||
(None, None, Some(f)) => {
|
||||
let id = self.asset(&f.asset, &what);
|
||||
let a = self.l().get_asset(id).expect("registered").clone();
|
||||
let amount = |s: &Option<String>, was: Money| {
|
||||
s.as_deref()
|
||||
.map_or(was, |v| Money::new(exact(v, &what), self.currency))
|
||||
};
|
||||
let fix = ledger_core::AssetCorrection {
|
||||
cost: amount(&f.cost, a.cost),
|
||||
salvage_value: amount(&f.salvage, a.salvage_value),
|
||||
useful_life_months: f.life_months.unwrap_or(a.useful_life_months),
|
||||
in_service_date: f
|
||||
.in_service
|
||||
.as_deref()
|
||||
.map_or(a.in_service_date, |d| date(d, &what)),
|
||||
};
|
||||
took(self.store.correct_asset(id, fix, &f.reason), &what);
|
||||
}
|
||||
(None, Some(policy), None) => {
|
||||
assert!(
|
||||
matches!(policy.as_str(), "warn" | "refuse"),
|
||||
"{what}: require_tax_category {policy:?}"
|
||||
@@ -1411,7 +1516,7 @@ impl Books<'_> {
|
||||
&what,
|
||||
);
|
||||
}
|
||||
_ => panic!("{what}: a change is one of rename or require_tax_category"),
|
||||
_ => panic!("{what}: a change is one of rename, require_tax_category or correct_asset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2201,8 +2306,8 @@ fn check_depreciation_schedule(b: &Books<'_>, x: &Expect) {
|
||||
for (key, want) in x.figures(&ex.id) {
|
||||
let want = exact(want, &format!("{what}: {key}"));
|
||||
let got = match key.as_str() {
|
||||
"accumulated" => ledger_core::accumulated_depreciation(asset, as_of),
|
||||
"book_value" => ledger_core::book_value(asset, as_of),
|
||||
"accumulated" => ledger_core::accumulated_scheduled_through(asset, as_of),
|
||||
"book_value" => ledger_core::scheduled_book_value(asset, as_of),
|
||||
month => {
|
||||
month_key(month, &what);
|
||||
Ok(lines
|
||||
@@ -2255,6 +2360,254 @@ fn check_schedule_plan(b: &Books<'_>, x: &Expect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check expectations whose values are named figures: each key one of
|
||||
/// `figures`' (an unknown one refused), its value the figure's text.
|
||||
fn named<K: Borrow<str> + Ord>(
|
||||
ex: &Example,
|
||||
x: &Expect,
|
||||
what: &str,
|
||||
figures: &BTreeMap<K, String>,
|
||||
) {
|
||||
for (key, want) in x.figures(&ex.id) {
|
||||
let got = figures
|
||||
.get(key.as_str())
|
||||
.unwrap_or_else(|| panic!("{what}: no figure {key:?}"));
|
||||
assert_eq!(got, want, "{what}: {key}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A decimal figure as an example writes it: two places at least, as the
|
||||
/// books keep money (exact; "0.00", "-14.20").
|
||||
fn figure(d: Decimal) -> String {
|
||||
let mut d = d.normalize();
|
||||
if d.scale() < 2 {
|
||||
d.rescale(2);
|
||||
}
|
||||
d.to_string()
|
||||
}
|
||||
|
||||
/// Check one `register_row` expectation: the asset's row of the fixed
|
||||
/// asset register as of `as_of` (rules.md B-15 rules 7 and 8, B-24):
|
||||
/// `acquired` and `in_service` (`YYYY-MM-DD`), `cost`,
|
||||
/// `accumulated_scheduled` (the schedule's), `accumulated_posted` (what
|
||||
/// the books carry), `unposted` (the one less the other), `book_value`
|
||||
/// (cost less posted); each 0.00 once disposed; the example names the
|
||||
/// figures it shows. An asset not on the register by `as_of` (acquired
|
||||
/// later) is refused.
|
||||
fn check_register_row(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let as_of = x.day(ex);
|
||||
let name = x
|
||||
.asset
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("{}: register_row names its asset", ex.id));
|
||||
let what = format!("{} register_row of {name} as of {as_of}", ex.id);
|
||||
let id = b.asset(name, &what);
|
||||
let r =
|
||||
ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let row = r
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.asset_id == id)
|
||||
.unwrap_or_else(|| panic!("{what}: not on the register (acquired later)"));
|
||||
let figures = BTreeMap::from([
|
||||
("acquired", row.acquired_date.to_string()),
|
||||
("in_service", row.in_service_date.to_string()),
|
||||
("cost", figure(row.cost)),
|
||||
("accumulated_scheduled", figure(row.accumulated_scheduled)),
|
||||
("accumulated_posted", figure(row.accumulated_posted)),
|
||||
(
|
||||
"unposted",
|
||||
figure(row.accumulated_scheduled - row.accumulated_posted),
|
||||
),
|
||||
("book_value", figure(row.book_value)),
|
||||
]);
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// Check one `register_totals` expectation: the fixed asset register's
|
||||
/// totals as of `as_of`, over the assets held (rules.md B-24, B-25):
|
||||
/// `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`,
|
||||
/// `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`,
|
||||
/// `missing_basis` (how many held have none), and how it reconciles to
|
||||
/// the books: `unregistered_cost`, `unposted_depreciation`,
|
||||
/// `unattributed_accumulated`; the example names the figures it shows.
|
||||
fn check_register_totals(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let as_of = x.day(ex);
|
||||
let what = format!("{} register_totals as of {as_of}", ex.id);
|
||||
let r =
|
||||
ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let figures = BTreeMap::from([
|
||||
("total_cost", figure(r.total_cost)),
|
||||
(
|
||||
"total_accumulated_scheduled",
|
||||
figure(r.total_accumulated_scheduled),
|
||||
),
|
||||
(
|
||||
"total_accumulated_posted",
|
||||
figure(r.total_accumulated_posted),
|
||||
),
|
||||
("total_book_value", figure(r.total_book_value)),
|
||||
("total_depreciable_basis", figure(r.total_depreciable_basis)),
|
||||
("total_tax_basis_723", figure(r.total_tax_basis_723)),
|
||||
("missing_basis", r.missing_basis.to_string()),
|
||||
("unregistered_cost", figure(r.unregistered_cost())),
|
||||
("unposted_depreciation", figure(r.unposted_depreciation())),
|
||||
(
|
||||
"unattributed_accumulated",
|
||||
figure(r.unattributed_accumulated()),
|
||||
),
|
||||
]);
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// Check one `asset_basis` expectation: the asset's basis facts and the
|
||||
/// tax figures they give (rules.md B-25): `rule`,
|
||||
/// `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`,
|
||||
/// `depreciable_basis`, `built_in_gain_704c` (a loss negative), each
|
||||
/// `"none"` where the asset has none (a purchase's §723 basis), and
|
||||
/// `sec704c_1c` (`"true"`: a built-in loss, which counts only for the
|
||||
/// contributing member, IRC §704(c)(1)(C)); the example names the figures
|
||||
/// it shows.
|
||||
fn check_asset_basis(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
undated(ex, x);
|
||||
let name = x
|
||||
.asset
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("{}: asset_basis names its asset", ex.id));
|
||||
let what = format!("{} asset_basis of {name}", ex.id);
|
||||
let a = b
|
||||
.l()
|
||||
.get_asset(b.asset(name, &what))
|
||||
.expect("a registered asset");
|
||||
let f = a
|
||||
.basis_figures()
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"))
|
||||
.unwrap_or_else(|| panic!("{what}: no basis recorded"));
|
||||
let opt = |d: Option<Decimal>| d.map_or("none".to_string(), figure);
|
||||
let rule = a.basis.as_ref().expect("a basis recorded").rule;
|
||||
let figures = BTreeMap::from([
|
||||
("rule", rule.key().to_string()),
|
||||
(
|
||||
"contributor_adjusted_basis",
|
||||
opt(a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis)),
|
||||
),
|
||||
("fmv_at_contribution", opt(a.fmv_at_contribution())),
|
||||
("tax_basis_723", opt(f.tax_basis_723)),
|
||||
("depreciable_basis", figure(f.depreciable_basis)),
|
||||
("built_in_gain_704c", opt(f.built_in_gain_704c)),
|
||||
("sec704c_1c", a.sec704c_1c().to_string()),
|
||||
]);
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// The tax-basis capital worksheet for `[from, to]` (rules.md B-36), a
|
||||
/// row a member, as `k1` reads the book one: it needs the members and
|
||||
/// their shares, and `cash_accounts`.
|
||||
fn tax_capital_rows(b: &Books<'_>, x: &Expect, what: &str) -> Vec<TaxCapitalRow> {
|
||||
let ex = b.ex;
|
||||
let (from, to) = x.period(ex);
|
||||
assert!(
|
||||
!b.members.is_empty() && b.members.iter().all(|m| m.share > Decimal::ZERO),
|
||||
"{what}: needs the members and their shares"
|
||||
);
|
||||
assert!(
|
||||
!ex.setup.cash_accounts.is_empty(),
|
||||
"{what}: the K-1 worksheet needs cash_accounts"
|
||||
);
|
||||
let cash = codes(b.l(), &ex.setup.cash_accounts, &ex.id);
|
||||
ledger_core::tax_capital::tax_basis_capital(b.l(), from, to, &b.members, &cash)
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"))
|
||||
.rows
|
||||
}
|
||||
|
||||
/// Check one `k1_tax_basis` expectation: each member's capital account
|
||||
/// for `[from, to]` on the tax basis, as Schedule K-1 Item L reports it
|
||||
/// (rules.md B-36), `"<member>.<line>"` for the lines `beginning`,
|
||||
/// `contributions_cash`, `contributions_property`, `net_income`, `other`,
|
||||
/// `distributions` and `ending`, beside `k1`'s book figures; a line the
|
||||
/// books can't know is `"open"`, with what they know so far as
|
||||
/// `"<member>.known_<line>"`. The example names the figures it shows.
|
||||
fn check_k1_tax_basis(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let what = format!("{} k1_tax_basis", ex.id);
|
||||
let mut figures = BTreeMap::new();
|
||||
for row in tax_capital_rows(b, x, &what) {
|
||||
let m = &row.book.name;
|
||||
figures.insert(
|
||||
format!("{m}.contributions_cash"),
|
||||
figure(row.contributions_cash),
|
||||
);
|
||||
for (line, tax) in [
|
||||
("beginning", &row.beginning),
|
||||
("contributions_property", &row.contributions_property),
|
||||
("net_income", &row.net_income),
|
||||
("other", &row.other),
|
||||
("distributions", &row.distributions),
|
||||
("ending", &row.ending),
|
||||
] {
|
||||
figures.insert(
|
||||
format!("{m}.{line}"),
|
||||
tax.tax().map_or("open".to_string(), figure),
|
||||
);
|
||||
figures.insert(format!("{m}.known_{line}"), figure(tax.known));
|
||||
}
|
||||
}
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// The causes `k1_reconciliation` reports, each member's every one.
|
||||
const RECONCILING_CAUSES: [&str; 5] = [
|
||||
"beginning",
|
||||
"built_in_gain_704c",
|
||||
"property_without_basis",
|
||||
"property_distributed",
|
||||
"total",
|
||||
];
|
||||
|
||||
/// Check one `k1_reconciliation` expectation: each member's book capital
|
||||
/// less tax-basis capital at the end of `[from, to]`, by cause (rules.md
|
||||
/// B-36), `"<member>.<cause>"` for `beginning`, `built_in_gain_704c`
|
||||
/// (their share of the §704(c) gain or loss at contribution),
|
||||
/// `property_without_basis`, `property_distributed` and `total`: the
|
||||
/// example names the members it shows, each with every cause.
|
||||
fn check_k1_reconciliation(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let what = format!("{} k1_reconciliation", ex.id);
|
||||
let mut figures = BTreeMap::new();
|
||||
for row in tax_capital_rows(b, x, &what) {
|
||||
let r = &row.reconciliation;
|
||||
let total = r
|
||||
.total()
|
||||
.unwrap_or_else(|| panic!("{what}: the total is too large"));
|
||||
for (cause, v) in RECONCILING_CAUSES.into_iter().zip([
|
||||
r.beginning,
|
||||
r.built_in_gain_704c,
|
||||
r.property_without_basis,
|
||||
r.property_distributed,
|
||||
total,
|
||||
]) {
|
||||
figures.insert(format!("{}.{cause}", row.book.name), figure(v));
|
||||
}
|
||||
}
|
||||
let named_members: BTreeSet<&str> = x
|
||||
.figures(&ex.id)
|
||||
.keys()
|
||||
.map(|k| k.split_once('.').map_or(k.as_str(), |(m, _)| m))
|
||||
.collect();
|
||||
for m in named_members {
|
||||
for cause in RECONCILING_CAUSES {
|
||||
assert!(
|
||||
x.figures(&ex.id).contains_key(&format!("{m}.{cause}")),
|
||||
"{what}: {m} is shown with every cause: {m}.{cause} is missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
named(ex, x, &what, &figures);
|
||||
}
|
||||
|
||||
/// The commands an example may try: each on a copy of the books, so it
|
||||
/// changes nothing that is checked after it.
|
||||
const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"];
|
||||
@@ -2474,7 +2827,12 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
||||
only(
|
||||
x.asset.is_some(),
|
||||
"asset",
|
||||
&["disposal", "depreciation_schedule"],
|
||||
&[
|
||||
"disposal",
|
||||
"depreciation_schedule",
|
||||
"register_row",
|
||||
"asset_basis",
|
||||
],
|
||||
);
|
||||
only(x.schedule.is_some(), "schedule", &["schedule_plan"]);
|
||||
only(
|
||||
@@ -2535,6 +2893,11 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
||||
"disposal" => check_disposal(b, x),
|
||||
"depreciation_schedule" => check_depreciation_schedule(b, x),
|
||||
"schedule_plan" => check_schedule_plan(b, x),
|
||||
"register_row" => check_register_row(b, x),
|
||||
"register_totals" => check_register_totals(b, x),
|
||||
"asset_basis" => check_asset_basis(b, x),
|
||||
"k1_tax_basis" => check_k1_tax_basis(b, x),
|
||||
"k1_reconciliation" => check_k1_reconciliation(b, x),
|
||||
"balances" => check_balances(ex, l, x),
|
||||
"members_equity" => check_members_equity(ex, l, &b.members, x),
|
||||
"k1" => check_k1(ex, l, &b.members, x),
|
||||
@@ -2632,7 +2995,8 @@ fn every_bookspec_example_holds() {
|
||||
// Each query the examples use ran: the cash flow's six examples at
|
||||
// least, the as-of and 1099 examples', the members closes', the
|
||||
// formats appendix's, and P3's default period, rename, tax category,
|
||||
// parties, 1099, depreciation and schedules examples'.
|
||||
// parties, 1099, depreciation, schedules, asset basis and K-1 tax-basis
|
||||
// examples'.
|
||||
for (q, at_least) in [
|
||||
("cash_flow", 6),
|
||||
("trial_balance", 2),
|
||||
@@ -2642,7 +3006,7 @@ fn every_bookspec_example_holds() {
|
||||
("entry", 8),
|
||||
("balances", 4),
|
||||
("members_equity", 10),
|
||||
("k1", 2),
|
||||
("k1", 3),
|
||||
("party_part", 28),
|
||||
("party_totals", 23),
|
||||
("party_tin_last4", 5),
|
||||
@@ -2660,6 +3024,11 @@ fn every_bookspec_example_holds() {
|
||||
("disposal", 5),
|
||||
("depreciation_schedule", 5),
|
||||
("schedule_plan", 3),
|
||||
("register_row", 5),
|
||||
("register_totals", 1),
|
||||
("asset_basis", 6),
|
||||
("k1_tax_basis", 2),
|
||||
("k1_reconciliation", 1),
|
||||
] {
|
||||
let n = checked.get(q).copied().unwrap_or(0);
|
||||
assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}");
|
||||
@@ -3147,3 +3516,52 @@ fn a_schedule_month_off_by_a_cent_fails() {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// An asset is on the register from its acquired date: a row asked for
|
||||
/// before it is refused, never shown with figures.
|
||||
#[test]
|
||||
#[should_panic(expected = "not on the register (acquired later)")]
|
||||
fn a_register_row_before_the_asset_was_acquired_is_refused() {
|
||||
run_edited("assets-acquired-before-in-service.toml", |t| {
|
||||
t.replacen("as_of = \"2026-03-31\"", "as_of = \"2026-03-09\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// `acquired_by` names an event whose entry put the asset on the books:
|
||||
/// an event that posts no entry can't.
|
||||
#[test]
|
||||
#[should_panic(expected = "event 2 posted 0 entries")]
|
||||
fn an_asset_acquired_by_an_event_with_no_entry_is_refused() {
|
||||
run_edited("assets-a-reversed-run-is-not-posted.toml", |t| {
|
||||
t.replacen("acquired_by = 1", "acquired_by = 2", 1)
|
||||
.replacen(
|
||||
"[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2026-01\" }",
|
||||
"[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2025-12\" }\nrefused = \"invalid_depreciation_period\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// A tax line the books can't know is "open", never a figure: an example
|
||||
/// giving the book figure for it fails.
|
||||
#[test]
|
||||
#[should_panic(expected = "k1_tax_basis: Tobin Harlow.net_income")]
|
||||
fn a_tax_line_the_books_cant_know_is_open() {
|
||||
run_edited("partnership-k1-tax-basis-capital.toml", |t| {
|
||||
t.replacen(
|
||||
"\"Tobin Harlow.net_income\" = \"open\"",
|
||||
"\"Tobin Harlow.net_income\" = \"-385.00\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// A member's reconciliation is shown whole: an example leaving out a
|
||||
/// cause fails, so a figure can't quietly go unchecked.
|
||||
#[test]
|
||||
#[should_panic(expected = "Tobin Harlow.property_distributed is missing")]
|
||||
fn a_members_reconciliation_shows_every_cause() {
|
||||
run_edited("partnership-k1-tax-basis-capital.toml", |t| {
|
||||
t.replacen(", \"Tobin Harlow.property_distributed\" = \"0.00\"", "", 1)
|
||||
});
|
||||
}
|
||||
@@ -1035,7 +1035,7 @@ fn a_sale_to_a_contractor_does_not_lower_its_1099() {
|
||||
let depr = acct(&mut m.l, "6800", AccountType::Expense);
|
||||
m.l.register_asset(NewAsset {
|
||||
description: "Monitor".into(),
|
||||
purchase_date: d(2024, 1, 1),
|
||||
in_service_date: d(2024, 1, 1),
|
||||
cost: Money::new(dec("1000"), Currency::USD),
|
||||
salvage_value: Money::new(dec("0"), Currency::USD),
|
||||
useful_life_months: 36,
|
||||
|
||||
@@ -863,7 +863,7 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
||||
let asset = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
in_service_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
cost: usd(60_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 60,
|
||||
|
||||
@@ -461,4 +461,27 @@ proptest! {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// kernel::basis_figures at i64 (what Kani's basis_figures_laws
|
||||
/// proved) and at Decimal agree, rule by rule.
|
||||
#[test]
|
||||
fn basis_figures_agree(
|
||||
rule in prop_oneof![
|
||||
Just(ledger_core::BasisRule::Purchase),
|
||||
Just(ledger_core::BasisRule::Carryover723),
|
||||
Just(ledger_core::BasisRule::PersonalUseConversion),
|
||||
],
|
||||
cost in arb_cents(),
|
||||
adjusted in arb_cents(),
|
||||
fmv in arb_cents(),
|
||||
) {
|
||||
let i = kernel::basis_figures(rule, cost, adjusted, fmv);
|
||||
let d = kernel::basis_figures(rule, dec(cost), dec(adjusted), dec(fmv));
|
||||
prop_assert_eq!(i.is_some(), d.is_some());
|
||||
if let (Some(i), Some(d)) = (i, d) {
|
||||
prop_assert_eq!(i.tax_basis_723.map(dec), d.tax_basis_723);
|
||||
prop_assert_eq!(dec(i.depreciable_basis), d.depreciable_basis);
|
||||
prop_assert_eq!(i.built_in_gain_704c.map(dec), d.built_in_gain_704c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_scheduled_through,
|
||||
depreciation_schedule,
|
||||
reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
|
||||
unclosed_earnings,
|
||||
},
|
||||
scheduled_book_value,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -290,7 +291,7 @@ proptest! {
|
||||
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
|
||||
let id = ledger.register_asset(NewAsset {
|
||||
description: "prop".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
|
||||
in_service_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
|
||||
cost: Money::new(cost, Currency::USD),
|
||||
salvage_value: Money::new(salvage, Currency::USD),
|
||||
useful_life_months: life,
|
||||
@@ -299,7 +300,7 @@ proptest! {
|
||||
expense_account: ex,
|
||||
}).unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
|
||||
let far = asset.in_service_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
|
||||
let sched = depreciation_schedule(asset, far).unwrap();
|
||||
// The lifetime total is exactly cost minus salvage: never a penny off.
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
@@ -314,8 +315,8 @@ proptest! {
|
||||
}
|
||||
// Book value at any date matches the schedule.
|
||||
if let Some(last) = sched.last() {
|
||||
prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value);
|
||||
prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated);
|
||||
prop_assert_eq!(scheduled_book_value(asset, far).unwrap(), last.book_value);
|
||||
prop_assert_eq!(accumulated_scheduled_through(asset, far).unwrap(), last.accumulated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,7 +507,7 @@ mod p3 {
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "prop".to_string(),
|
||||
purchase_date: start,
|
||||
in_service_date: start,
|
||||
cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD),
|
||||
salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD),
|
||||
useful_life_months: life,
|
||||
@@ -631,7 +632,7 @@ proptest! {
|
||||
let mut prev = Decimal::ZERO;
|
||||
for day in days {
|
||||
let s = depreciation_schedule(a, day).unwrap();
|
||||
let acc = accumulated_depreciation(a, day).unwrap();
|
||||
let acc = accumulated_scheduled_through(a, day).unwrap();
|
||||
prop_assert_eq!(s.iter().map(|l| l.amount).sum::<Decimal>(), acc);
|
||||
prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO));
|
||||
prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev);
|
||||
@@ -673,10 +674,10 @@ proptest! {
|
||||
let full = depreciation_schedule(a, NaiveDate::MAX).unwrap();
|
||||
prop_assert_eq!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated);
|
||||
prop_assert_eq!(
|
||||
accumulated_depreciation(a, on + chrono::Days::new(later)).unwrap(),
|
||||
accumulated_scheduled_through(a, on + chrono::Days::new(later)).unwrap(),
|
||||
want.accumulated
|
||||
);
|
||||
prop_assert_eq!(book_value(a, on).unwrap(), want.book_value);
|
||||
prop_assert_eq!(scheduled_book_value(a, on).unwrap(), want.book_value);
|
||||
}
|
||||
|
||||
/// B-15 rule 3 (defect 22), against arithmetic written here: through
|
||||
@@ -711,7 +712,7 @@ proptest! {
|
||||
x / y + i128::from(2 * (x % y) >= y)
|
||||
};
|
||||
prop_assert_eq!(
|
||||
accumulated_depreciation(a, through).unwrap(),
|
||||
accumulated_scheduled_through(a, through).unwrap(),
|
||||
Decimal::new(want as i64, 2),
|
||||
"{} of {}", through, last_day
|
||||
);
|
||||
@@ -882,3 +883,53 @@ proptest! {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arb_basis_rule() -> impl Strategy<Value = ledger_core::BasisRule> {
|
||||
prop_oneof![
|
||||
Just(ledger_core::BasisRule::Purchase),
|
||||
Just(ledger_core::BasisRule::Carryover723),
|
||||
Just(ledger_core::BasisRule::PersonalUseConversion),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(5000))]
|
||||
|
||||
/// kernel::basis_figures INVARIANTS 1-4 on Decimal money (rules.md
|
||||
/// B-25): a purchase depreciates from cost and has no §723 basis or
|
||||
/// built-in gain; a contribution's §723 basis is the adjusted basis,
|
||||
/// its built-in gain fair market value less that basis, negative
|
||||
/// exactly when the basis exceeds the value; carryover depreciates
|
||||
/// from the basis, personal use from the lesser (at most each, equal
|
||||
/// to one).
|
||||
#[test]
|
||||
fn prop_basis_figures_laws(
|
||||
rule in arb_basis_rule(),
|
||||
cost in 1i64..=100_000_000_000,
|
||||
adjusted in 0i64..=100_000_000_000,
|
||||
fmv in 0i64..=100_000_000_000,
|
||||
) {
|
||||
use ledger_core::BasisRule::*;
|
||||
let (cost, adjusted, fmv) = (Decimal::new(cost, 2), Decimal::new(adjusted, 2), Decimal::new(fmv, 2));
|
||||
let f = ledger_core::kernel::basis_figures(rule, cost, adjusted, fmv).unwrap();
|
||||
match rule {
|
||||
Purchase => {
|
||||
prop_assert_eq!(f.depreciable_basis, cost);
|
||||
prop_assert_eq!(f.tax_basis_723, None);
|
||||
prop_assert_eq!(f.built_in_gain_704c, None);
|
||||
}
|
||||
Carryover723 | PersonalUseConversion => {
|
||||
prop_assert_eq!(f.tax_basis_723, Some(adjusted));
|
||||
let g = f.built_in_gain_704c.unwrap();
|
||||
prop_assert_eq!(g, fmv - adjusted);
|
||||
prop_assert_eq!(g < Decimal::ZERO, adjusted > fmv);
|
||||
if rule == Carryover723 {
|
||||
prop_assert_eq!(f.depreciable_basis, adjusted);
|
||||
} else {
|
||||
prop_assert!(f.depreciable_basis <= adjusted && f.depreciable_basis <= fmv);
|
||||
prop_assert!(f.depreciable_basis == adjusted || f.depreciable_basis == fmv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 905ded9e8e77bbcda0f9dfa53feb4e5279611111e7de70f9ab53d8b2ff6040ba # shrinks to assets = [(0.01, 0.00, 0.01, 2)]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@
|
||||
//! `AssetRegister`, and `docs/spec/rules.md` B-15 and B-24. Depreciation
|
||||
//! amounts are worked by hand (straight line, whole months) or, for
|
||||
//! mid-month dates, taken from the already-tested building block
|
||||
//! `accumulated_depreciation`. Nothing is read back from the code under
|
||||
//! `accumulated_scheduled_through`. Nothing is read back from the code under
|
||||
//! test and then asserted.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -21,7 +21,7 @@ use ledger_core::assets::{
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
||||
accumulated_depreciation, fixed_asset_register,
|
||||
accumulated_scheduled_through, fixed_asset_register,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -102,7 +102,7 @@ fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) {
|
||||
fn equipment(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset {
|
||||
NewAsset {
|
||||
description: description.to_string(),
|
||||
purchase_date: on,
|
||||
in_service_date: on,
|
||||
cost: usd(cost),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: life,
|
||||
@@ -715,6 +715,22 @@ fn depreciate(l: &mut Ledger, a: &Accts, accum: AccountId, on: NaiveDate, amt: &
|
||||
);
|
||||
}
|
||||
|
||||
/// One asset's depreciation run for the month of `on`, as a store posts
|
||||
/// and records it: Dr depreciation expense / Cr its accumulated
|
||||
/// depreciation, then the run (Ledger::record_depreciation_run).
|
||||
fn run(l: &mut Ledger, a: &Accts, asset: AssetId, on: NaiveDate, amt: &str) {
|
||||
let accum = l.get_asset(asset).unwrap().accum_depr_account;
|
||||
let e = l.try_create_entry(on, "depreciation").unwrap();
|
||||
l.add_line(e, a.depr_expense, Side::Debit, usd(amt))
|
||||
.unwrap();
|
||||
l.add_line(e, accum, Side::Credit, usd(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
let period = on.format("%Y-%m").to_string();
|
||||
l.check_depreciation_run(asset, &period, e, d(amt)).unwrap();
|
||||
l.record_depreciation_run(asset, &period, e, d(amt))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Buy equipment for cash: Dr cost account / Cr 1000.
|
||||
fn buy(l: &mut Ledger, a: &Accts, account: AccountId, on: NaiveDate, amt: &str) {
|
||||
post(
|
||||
@@ -747,7 +763,8 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) {
|
||||
.register_asset(equipment(&a, "Server", "2400.00", 24, date(2026, 1, 1)))
|
||||
.unwrap();
|
||||
for m in 1..=3 {
|
||||
depreciate(&mut l, &a, a.accum, month_end(m), "1100.00");
|
||||
run(&mut l, &a, laptop, month_end(m), "1000.00");
|
||||
run(&mut l, &a, server, month_end(m), "100.00");
|
||||
}
|
||||
(l, a, laptop, server)
|
||||
}
|
||||
@@ -755,22 +772,27 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) {
|
||||
/// INVARIANT 3: totals are the sums over held (not disposed) rows.
|
||||
fn assert_totals_are_sums(r: &ledger_core::AssetRegister) {
|
||||
let held = r.rows.iter().filter(|x| x.disposed.is_none());
|
||||
let (mut c, mut acc, mut bv) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
let (mut c, mut acc, mut posted, mut bv) =
|
||||
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
for x in held {
|
||||
c += x.cost;
|
||||
acc += x.accumulated;
|
||||
acc += x.accumulated_scheduled;
|
||||
posted += x.accumulated_posted;
|
||||
bv += x.book_value;
|
||||
}
|
||||
assert_eq!(r.total_cost, c);
|
||||
assert_eq!(r.total_accumulated, acc);
|
||||
assert_eq!(r.total_accumulated_scheduled, acc);
|
||||
assert_eq!(r.total_accumulated_posted, posted);
|
||||
assert_eq!(r.total_book_value, bv);
|
||||
for x in &r.rows {
|
||||
// INVARIANT 2 (held) / INVARIANT 1 (disposed).
|
||||
if x.disposed.is_some() {
|
||||
assert_eq!(x.accumulated, Decimal::ZERO);
|
||||
assert_eq!(x.accumulated_scheduled, Decimal::ZERO);
|
||||
assert_eq!(x.accumulated_posted, Decimal::ZERO);
|
||||
assert_eq!(x.book_value, Decimal::ZERO);
|
||||
} else {
|
||||
assert_eq!(x.book_value, x.cost - x.accumulated);
|
||||
// Book value is what the books carry: cost less posted.
|
||||
assert_eq!(x.book_value, x.cost - x.accumulated_posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,42 +812,52 @@ fn register_agrees_with_the_books_when_depreciation_is_posted() {
|
||||
assert_eq!(x.tag.as_deref(), Some("FL-0001"));
|
||||
assert_eq!(x.serial.as_deref(), Some("C02XG0FDJGH5"));
|
||||
assert_eq!(x.description, "Laptop");
|
||||
assert_eq!(x.in_service, date(2026, 1, 1));
|
||||
assert_eq!(x.in_service_date, date(2026, 1, 1));
|
||||
assert_eq!(x.cost, d("12000.00"));
|
||||
assert_eq!(x.salvage, d("0.00"));
|
||||
assert_eq!(x.useful_life_months, 12);
|
||||
assert_eq!(x.accumulated, d("3000.00"));
|
||||
assert_eq!(x.accumulated_scheduled, d("3000.00"));
|
||||
assert_eq!(x.accumulated_posted, d("3000.00"));
|
||||
assert_eq!(x.book_value, d("9000.00"));
|
||||
assert_eq!(x.disposed, None);
|
||||
let y = &r.rows[1];
|
||||
assert_eq!(y.tag, None);
|
||||
assert_eq!(y.serial, None);
|
||||
assert_eq!(y.accumulated, d("300.00"));
|
||||
assert_eq!(y.accumulated_scheduled, d("300.00"));
|
||||
assert_eq!(y.accumulated_posted, d("300.00"));
|
||||
assert_eq!(y.book_value, d("2100.00"));
|
||||
assert_eq!(r.total_cost, d("14400.00"));
|
||||
assert_eq!(r.total_accumulated, d("3300.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("3300.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("3300.00"));
|
||||
assert_eq!(r.total_book_value, d("11100.00"));
|
||||
// INVARIANT 4: the GL, each shared account counted once.
|
||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("3300.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
|
||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
|
||||
/// unposted_depreciation: due by schedule but not in the books.
|
||||
/// unposted_depreciation: due by schedule but not in the books. Book
|
||||
/// value stays what the books carry (cost less March's posted figure),
|
||||
/// never the schedule's April figure.
|
||||
#[test]
|
||||
fn register_shows_unposted_depreciation() {
|
||||
let (l, _, _, _) = register_fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 4, 30)).unwrap();
|
||||
assert_eq!(r.total_accumulated, d("4400.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("4400.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("3300.00"));
|
||||
assert_eq!(r.total_book_value, d("11100.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("3300.00"));
|
||||
assert_eq!(r.unposted_depreciation(), d("1100.00"));
|
||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
|
||||
/// Negative unposted_depreciation when the books carry more.
|
||||
/// Negative unposted_depreciation when the books carry more; a hand
|
||||
/// entry no asset's run accounts for is unattributed, and no asset's book
|
||||
/// value takes it.
|
||||
#[test]
|
||||
fn register_shows_overposted_depreciation_as_negative() {
|
||||
let (mut l, a, _, _) = register_fixture();
|
||||
@@ -833,6 +865,8 @@ fn register_shows_overposted_depreciation_as_negative() {
|
||||
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
|
||||
assert_eq!(r.ledger_accumulated, d("3350.00"));
|
||||
assert_eq!(r.unposted_depreciation(), d("-50.00"));
|
||||
assert_eq!(r.unattributed_accumulated(), d("50.00"));
|
||||
assert_eq!(r.total_book_value, d("11100.00"));
|
||||
}
|
||||
|
||||
/// The GL balances are on as_of: later postings don't count.
|
||||
@@ -840,25 +874,27 @@ fn register_shows_overposted_depreciation_as_negative() {
|
||||
fn register_ledger_balances_are_as_of_the_date() {
|
||||
let (l, _, _, _) = register_fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 2, 28)).unwrap();
|
||||
assert_eq!(r.total_accumulated, d("2200.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("2200.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("2200.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("2200.00"));
|
||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
/// INVARIANT 2 mid-month: accumulated is accumulated_depreciation through
|
||||
/// as_of.
|
||||
/// INVARIANT 2 mid-month: accumulated_scheduled is accumulated_scheduled_through
|
||||
/// through as_of; book value stays cost less what is posted.
|
||||
#[test]
|
||||
fn register_accumulated_mid_month_follows_the_schedule() {
|
||||
let (l, _, laptop, server) = register_fixture();
|
||||
let on = date(2026, 5, 17);
|
||||
let r = fixed_asset_register(&l, on).unwrap();
|
||||
let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on).unwrap();
|
||||
let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on).unwrap();
|
||||
assert_eq!(r.rows[0].accumulated, la);
|
||||
assert_eq!(r.rows[1].accumulated, sa);
|
||||
assert_eq!(r.rows[0].book_value, d("12000.00") - la);
|
||||
assert_eq!(r.total_accumulated, la + sa);
|
||||
let la = accumulated_scheduled_through(l.get_asset(laptop).unwrap(), on).unwrap();
|
||||
let sa = accumulated_scheduled_through(l.get_asset(server).unwrap(), on).unwrap();
|
||||
assert_eq!(r.rows[0].accumulated_scheduled, la);
|
||||
assert_eq!(r.rows[1].accumulated_scheduled, sa);
|
||||
assert_eq!(r.rows[0].accumulated_posted, d("3000.00"));
|
||||
assert_eq!(r.rows[0].book_value, d("9000.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, la + sa);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
|
||||
@@ -881,7 +917,8 @@ fn register_shows_unregistered_cost() {
|
||||
assert_eq!(r2.unposted_depreciation(), d("100.00"));
|
||||
}
|
||||
|
||||
/// INVARIANT 1: only assets registered (placed in service) by as_of.
|
||||
/// INVARIANT 1: only assets acquired by as_of (with no acquisition entry
|
||||
/// linked, the in-service date).
|
||||
#[test]
|
||||
fn register_leaves_out_assets_not_yet_in_service() {
|
||||
let (mut l, a, laptop, server) = register_fixture();
|
||||
@@ -897,14 +934,14 @@ fn register_leaves_out_assets_not_yet_in_service() {
|
||||
let ids: Vec<AssetId> = r.rows.iter().map(|x| x.asset_id).collect();
|
||||
assert_eq!(ids, vec![laptop, server, chair]);
|
||||
assert_eq!(
|
||||
r.rows[2].accumulated,
|
||||
accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
|
||||
r.rows[2].accumulated_scheduled,
|
||||
accumulated_scheduled_through(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
|
||||
);
|
||||
// Before anything: empty and zero.
|
||||
let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap();
|
||||
assert!(r.rows.is_empty());
|
||||
assert_eq!(r.total_cost, Decimal::ZERO);
|
||||
assert_eq!(r.total_accumulated, Decimal::ZERO);
|
||||
assert_eq!(r.total_accumulated_scheduled, Decimal::ZERO);
|
||||
assert_eq!(r.total_book_value, Decimal::ZERO);
|
||||
}
|
||||
|
||||
@@ -912,8 +949,14 @@ fn register_leaves_out_assets_not_yet_in_service() {
|
||||
/// depreciation: book value 1,800, gain 200. Entry: Dr 1000 2,000,
|
||||
/// Dr 1510 600, Cr 1500 2,400, Cr 4900 200.
|
||||
fn dispose_server(l: &mut Ledger, a: &Accts, server: AssetId) {
|
||||
let laptop = l
|
||||
.registered_assets()
|
||||
.find(|x| x.description == "Laptop")
|
||||
.unwrap()
|
||||
.id;
|
||||
for m in 4..=6 {
|
||||
depreciate(l, a, a.accum, month_end(m), "1100.00");
|
||||
run(l, a, laptop, month_end(m), "1000.00");
|
||||
run(l, a, server, month_end(m), "100.00");
|
||||
}
|
||||
l.dispose_asset(server, date(2026, 6, 30), usd("2000.00"))
|
||||
.unwrap();
|
||||
@@ -938,18 +981,22 @@ fn register_shows_a_disposed_asset_outside_the_totals() {
|
||||
assert_eq!(r.rows.len(), 2, "a disposed asset still has a row");
|
||||
let s = r.rows.iter().find(|x| x.asset_id == server).unwrap();
|
||||
assert_eq!(s.disposed, Some((date(2026, 6, 30), d("2000.00"))));
|
||||
assert_eq!(s.accumulated, Decimal::ZERO);
|
||||
assert_eq!(s.accumulated_scheduled, Decimal::ZERO);
|
||||
assert_eq!(s.accumulated_posted, Decimal::ZERO);
|
||||
assert_eq!(s.book_value, Decimal::ZERO);
|
||||
assert_eq!(s.tag.as_deref(), Some("FL-0002"));
|
||||
assert_eq!(s.cost, d("2400.00"));
|
||||
let lp = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
||||
assert_eq!(lp.accumulated, d("6000.00"));
|
||||
assert_eq!(lp.accumulated_scheduled, d("6000.00"));
|
||||
assert_eq!(lp.accumulated_posted, d("6000.00"));
|
||||
assert_eq!(r.total_cost, d("12000.00"));
|
||||
assert_eq!(r.total_accumulated, d("6000.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("6000.00"));
|
||||
assert_eq!(r.total_accumulated_posted, d("6000.00"));
|
||||
assert_eq!(r.total_book_value, d("6000.00"));
|
||||
assert_eq!(r.ledger_cost, d("12000.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("6000.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
|
||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||
assert_totals_are_sums(&r);
|
||||
}
|
||||
@@ -962,10 +1009,11 @@ fn register_before_the_disposal_date_shows_the_asset_held() {
|
||||
let r = fixed_asset_register(&l, date(2026, 5, 31)).unwrap();
|
||||
let s = r.rows.iter().find(|x| x.asset_id == server).unwrap();
|
||||
assert_eq!(s.disposed, None);
|
||||
assert_eq!(s.accumulated, d("500.00"));
|
||||
assert_eq!(s.accumulated_scheduled, d("500.00"));
|
||||
assert_eq!(s.accumulated_posted, d("500.00"));
|
||||
assert_eq!(s.book_value, d("1900.00"));
|
||||
assert_eq!(r.total_cost, d("14400.00"));
|
||||
assert_eq!(r.total_accumulated, d("5500.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("5500.00"));
|
||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("5500.00"));
|
||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||
@@ -977,27 +1025,29 @@ fn register_before_the_disposal_date_shows_the_asset_held() {
|
||||
fn register_sums_the_gl_over_distinct_asset_accounts() {
|
||||
let (mut l, a, _, _) = register_fixture();
|
||||
buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "36000.00");
|
||||
l.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("36000.00"),
|
||||
salvage_value: usd("6000.00"),
|
||||
useful_life_months: 60,
|
||||
cost_account: a.vehicles,
|
||||
accum_depr_account: a.vehicles_accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap();
|
||||
let van = l
|
||||
.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd("36000.00"),
|
||||
salvage_value: usd("6000.00"),
|
||||
useful_life_months: 60,
|
||||
cost_account: a.vehicles,
|
||||
accum_depr_account: a.vehicles_accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap();
|
||||
// (36,000 − 6,000) / 60 = 500/month; Jan–Mar posted.
|
||||
for m in 1..=3 {
|
||||
depreciate(&mut l, &a, a.vehicles_accum, month_end(m), "500.00");
|
||||
run(&mut l, &a, van, month_end(m), "500.00");
|
||||
}
|
||||
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
|
||||
assert_eq!(r.rows[2].salvage, d("6000.00"));
|
||||
assert_eq!(r.rows[2].accumulated, d("1500.00"));
|
||||
assert_eq!(r.rows[2].accumulated_scheduled, d("1500.00"));
|
||||
assert_eq!(r.rows[2].accumulated_posted, d("1500.00"));
|
||||
assert_eq!(r.rows[2].book_value, d("34500.00"));
|
||||
assert_eq!(r.total_cost, d("50400.00"));
|
||||
assert_eq!(r.total_accumulated, d("4800.00"));
|
||||
assert_eq!(r.total_accumulated_scheduled, d("4800.00"));
|
||||
assert_eq!(r.total_book_value, d("45600.00"));
|
||||
assert_eq!(r.ledger_cost, d("50400.00"));
|
||||
assert_eq!(r.ledger_accumulated, d("4800.00"));
|
||||
@@ -1031,13 +1081,15 @@ fn register_of_no_assets_is_empty() {
|
||||
}
|
||||
|
||||
/// Defect 22 in the register (INVARIANT 2): an asset disposed of later in
|
||||
/// the same month as `as_of` is held on `as_of`, its accumulated
|
||||
/// depreciation prorated by days through `as_of`, never plugged to the
|
||||
/// whole. Hand check: the 12,000.00 laptop (12 months from 2026-01-01),
|
||||
/// disposed of 2026-06-20; on 2026-06-10, five months of 1,000.00 and
|
||||
/// June's 10/30: 5,333.33, book value 6,666.67. Before P3 the disposed
|
||||
/// the same month as `as_of` is held on `as_of`, its scheduled
|
||||
/// accumulated depreciation prorated by days through `as_of`, never
|
||||
/// plugged to the whole. Hand check: the 12,000.00 laptop (12 months from
|
||||
/// 2026-01-01), disposed of 2026-06-20; on 2026-06-10, five months of
|
||||
/// 1,000.00 and June's 10/30: 5,333.33 scheduled. Before P3 the disposed
|
||||
/// schedule ended in a plug, so the register showed 12,000.00 and 0.00.
|
||||
/// The same in the life's final month: on 2026-12-15, 11,483.87.
|
||||
/// The same in the life's final month: on 2026-12-15, 11,483.87. Book
|
||||
/// value is cost less what the books carry (defect 13, rules.md B-15 rule
|
||||
/// 8): the fixture's runs, January to March, 3,000.00, so 9,000.00.
|
||||
#[test]
|
||||
fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
||||
let (mut l, _, laptop, _) = register_fixture();
|
||||
@@ -1045,19 +1097,21 @@ fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
||||
.unwrap();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap();
|
||||
assert_eq!(r.rows[0].disposed, None);
|
||||
assert_eq!(r.rows[0].accumulated, d("5333.33"));
|
||||
assert_eq!(r.rows[0].book_value, d("6666.67"));
|
||||
assert_eq!(r.rows[0].accumulated_scheduled, d("5333.33"));
|
||||
// Book value is what the books carry: January to March's runs.
|
||||
assert_eq!(r.rows[0].accumulated_posted, d("3000.00"));
|
||||
assert_eq!(r.rows[0].book_value, d("9000.00"));
|
||||
assert_totals_are_sums(&r);
|
||||
// On the disposal day it is gone: no figures, in no total.
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 20)).unwrap();
|
||||
assert_eq!(r.rows[0].accumulated, Decimal::ZERO);
|
||||
assert_eq!(r.rows[0].accumulated_scheduled, Decimal::ZERO);
|
||||
assert!(r.rows[0].disposed.is_some());
|
||||
|
||||
let (l, _, laptop, _) = register_fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap();
|
||||
let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
||||
assert_eq!(
|
||||
(row.accumulated, row.book_value),
|
||||
(d("11483.87"), d("516.13"))
|
||||
(row.accumulated_scheduled, row.book_value),
|
||||
(d("11483.87"), d("9000.00"))
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ use ledger_core::payroll::{
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Asset, AssetId, AssetStatus, Currency, DepreciationMethod, EntryId,
|
||||
Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_depreciation, book_value,
|
||||
depreciation_schedule, disposal_amounts,
|
||||
Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_scheduled_through,
|
||||
depreciation_schedule, disposal_amounts, scheduled_book_value,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -49,7 +49,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
||||
Asset {
|
||||
id: 1,
|
||||
description: "Test asset".to_string(),
|
||||
purchase_date: purchase,
|
||||
in_service_date: purchase,
|
||||
cost: usd(cost),
|
||||
salvage_value: usd(salvage),
|
||||
useful_life_months: life,
|
||||
@@ -61,7 +61,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
||||
tag: None,
|
||||
serial: None,
|
||||
acquired_by: None,
|
||||
tax: None,
|
||||
basis: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fn fixture() -> (Ledger, Accts) {
|
||||
fn new_asset(a: &Accts) -> NewAsset {
|
||||
NewAsset {
|
||||
description: "Laptop".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd("12000.00"),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 12,
|
||||
@@ -377,9 +377,12 @@ fn salvage_value_is_never_depreciated_b15() {
|
||||
let amounts: Vec<Decimal> = s.iter().map(|l| l.amount).collect();
|
||||
assert_eq!(amounts, vec![d("1000.00"); 4]);
|
||||
assert_eq!(s.last().unwrap().book_value, d("1000.00"));
|
||||
assert_eq!(book_value(&a, date(2040, 1, 31)).unwrap(), d("1000.00"));
|
||||
assert_eq!(
|
||||
accumulated_depreciation(&a, date(2040, 1, 31)).unwrap(),
|
||||
scheduled_book_value(&a, date(2040, 1, 31)).unwrap(),
|
||||
d("1000.00")
|
||||
);
|
||||
assert_eq!(
|
||||
accumulated_scheduled_through(&a, date(2040, 1, 31)).unwrap(),
|
||||
d("4000.00")
|
||||
);
|
||||
}
|
||||
@@ -418,10 +421,13 @@ fn nothing_depreciates_before_purchase() {
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
accumulated_depreciation(&a, date(2025, 12, 31)).unwrap(),
|
||||
accumulated_scheduled_through(&a, date(2025, 12, 31)).unwrap(),
|
||||
d("0")
|
||||
);
|
||||
assert_eq!(book_value(&a, date(2025, 12, 31)).unwrap(), d("12000.00"));
|
||||
assert_eq!(
|
||||
scheduled_book_value(&a, date(2025, 12, 31)).unwrap(),
|
||||
d("12000.00")
|
||||
);
|
||||
}
|
||||
|
||||
/// Oracle: interface docs "accumulated through as_of" and "book value =
|
||||
@@ -430,10 +436,13 @@ fn nothing_depreciates_before_purchase() {
|
||||
fn accumulated_and_book_value_mid_life() {
|
||||
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||
assert_eq!(
|
||||
accumulated_depreciation(&a, date(2026, 6, 30)).unwrap(),
|
||||
accumulated_scheduled_through(&a, date(2026, 6, 30)).unwrap(),
|
||||
d("6000.00")
|
||||
);
|
||||
assert_eq!(
|
||||
scheduled_book_value(&a, date(2026, 6, 30)).unwrap(),
|
||||
d("6000.00")
|
||||
);
|
||||
assert_eq!(book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00"));
|
||||
}
|
||||
|
||||
// ------------------------------- defect 22: the final month, disposals
|
||||
@@ -454,7 +463,10 @@ fn defect22_depreciation_through_mid_final_month_is_prorated_not_plugged() {
|
||||
assert_eq!(dec.amount, d("483.87"));
|
||||
assert_eq!(dec.accumulated, d("11483.87"));
|
||||
assert_eq!(dec.book_value, d("516.13"));
|
||||
assert_eq!(book_value(&a, date(2026, 12, 15)).unwrap(), d("516.13"));
|
||||
assert_eq!(
|
||||
scheduled_book_value(&a, date(2026, 12, 15)).unwrap(),
|
||||
d("516.13")
|
||||
);
|
||||
// Through its last day, the whole of it.
|
||||
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
|
||||
assert_eq!(
|
||||
@@ -531,12 +543,12 @@ fn defect22_a_disposed_assets_schedule_stops_at_its_disposal_day() {
|
||||
);
|
||||
assert_eq!(jun.accumulated, before.accumulated);
|
||||
assert_eq!(
|
||||
accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
||||
accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||
d("5500.00")
|
||||
);
|
||||
// A through-date before the disposal is unchanged by it.
|
||||
assert_eq!(
|
||||
accumulated_depreciation(a, date(2026, 5, 31)).unwrap(),
|
||||
accumulated_scheduled_through(a, date(2026, 5, 31)).unwrap(),
|
||||
d("5000.00")
|
||||
);
|
||||
|
||||
@@ -680,12 +692,13 @@ fn disposal_refuses_negative_proceeds() {
|
||||
/// Oracle: LedgerError::DisposalBeforePurchase doc "disposal date must not
|
||||
/// precede the purchase date".
|
||||
#[test]
|
||||
fn disposal_refuses_date_before_purchase() {
|
||||
fn disposal_before_service_accrues_nothing() {
|
||||
// The floor is the acquired date, the ledger's to check
|
||||
// (Ledger::dispose_asset); the math alone has nothing accrued yet.
|
||||
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||
assert!(matches!(
|
||||
disposal_amounts(&a, date(2025, 12, 31), usd("100.00")),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
let got = disposal_amounts(&a, date(2025, 12, 31), usd("100.00")).unwrap();
|
||||
assert_eq!(got.accumulated, d("0"));
|
||||
assert_eq!(got.gain_loss, d("-11900.00"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- register / dispose
|
||||
@@ -702,7 +715,7 @@ fn register_asset_records_inputs_under_next_id() {
|
||||
assert_eq!(id, expected_id);
|
||||
let got = l.get_asset(id).unwrap();
|
||||
assert_eq!(got.description, "Laptop");
|
||||
assert_eq!(got.purchase_date, date(2026, 1, 1));
|
||||
assert_eq!(got.in_service_date, date(2026, 1, 1));
|
||||
assert_eq!(got.cost, usd("12000.00"));
|
||||
assert_eq!(got.salvage_value, usd("0.00"));
|
||||
assert_eq!(got.useful_life_months, 12);
|
||||
@@ -900,7 +913,7 @@ fn dispose_asset_refuses_bad_disposals_unchanged() {
|
||||
);
|
||||
assert!(matches!(
|
||||
l.dispose_asset(id, date(2025, 12, 1), usd("1.00")),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
Err(LedgerError::DisposalBeforeAcquisition { .. })
|
||||
));
|
||||
assert_eq!(snapshot(&l), before);
|
||||
}
|
||||
@@ -1317,7 +1330,7 @@ proptest! {
|
||||
prop_assert_eq!(total, cost - salvage);
|
||||
let life = a.useful_life_months as usize;
|
||||
prop_assert!(s.len() == life || s.len() == life + 1, "len {} life {}", s.len(), life);
|
||||
prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.purchase_date.year(), a.purchase_date.month()));
|
||||
prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.in_service_date.year(), a.in_service_date.month()));
|
||||
let mut acc = Decimal::ZERO;
|
||||
let mut prev_bv = cost;
|
||||
for (i, l) in s.iter().enumerate() {
|
||||
@@ -1336,7 +1349,7 @@ proptest! {
|
||||
prop_assert_eq!(s.last().unwrap().book_value, salvage);
|
||||
}
|
||||
|
||||
/// Oracle: accumulated_depreciation / book_value docs agree with the
|
||||
/// Oracle: accumulated_scheduled_through / book_value docs agree with the
|
||||
/// schedule at each month end, a shorter schedule is a prefix of the full
|
||||
/// one, and nothing is depreciated before purchase.
|
||||
#[test]
|
||||
@@ -1344,14 +1357,14 @@ proptest! {
|
||||
let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
|
||||
for (i, l) in full.iter().enumerate() {
|
||||
let end = period_end(&l.period);
|
||||
prop_assert_eq!(accumulated_depreciation(&a, end).unwrap(), l.accumulated);
|
||||
prop_assert_eq!(book_value(&a, end).unwrap(), l.book_value);
|
||||
prop_assert_eq!(accumulated_scheduled_through(&a, end).unwrap(), l.accumulated);
|
||||
prop_assert_eq!(scheduled_book_value(&a, end).unwrap(), l.book_value);
|
||||
let prefix = depreciation_schedule(&a, end).unwrap();
|
||||
prop_assert_eq!(&prefix[..], &full[..=i]);
|
||||
}
|
||||
let before = a.purchase_date.pred_opt().unwrap();
|
||||
prop_assert_eq!(accumulated_depreciation(&a, before).unwrap(), Decimal::ZERO);
|
||||
prop_assert_eq!(book_value(&a, before).unwrap(), a.cost.amount);
|
||||
let before = a.in_service_date.pred_opt().unwrap();
|
||||
prop_assert_eq!(accumulated_scheduled_through(&a, before).unwrap(), Decimal::ZERO);
|
||||
prop_assert_eq!(scheduled_book_value(&a, before).unwrap(), a.cost.amount);
|
||||
}
|
||||
|
||||
/// Oracle: B-15 partial months by days + interface "the last month absorbs
|
||||
@@ -1386,12 +1399,12 @@ proptest! {
|
||||
offset in 0u32..=200,
|
||||
proceeds in 0i64..=20_000_000,
|
||||
) {
|
||||
let when = month_end(a.purchase_date.checked_add_months(Months::new(offset)).unwrap());
|
||||
let when = month_end(a.in_service_date.checked_add_months(Months::new(offset)).unwrap());
|
||||
let pr = Decimal::new(proceeds, 2);
|
||||
let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap();
|
||||
prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when).unwrap());
|
||||
prop_assert_eq!(got.accumulated, accumulated_scheduled_through(&a, when).unwrap());
|
||||
prop_assert_eq!(got.book_value, a.cost.amount - got.accumulated);
|
||||
prop_assert_eq!(got.book_value, book_value(&a, when).unwrap());
|
||||
prop_assert_eq!(got.book_value, scheduled_book_value(&a, when).unwrap());
|
||||
prop_assert_eq!(got.gain_loss, pr - got.book_value);
|
||||
prop_assert!(got.book_value >= a.salvage_value.amount);
|
||||
}
|
||||
|
||||
@@ -230,9 +230,9 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
let equipment = add("1500", AccountType::Asset);
|
||||
let accum = add("1501", AccountType::Asset);
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
let asset = |purchase_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset {
|
||||
let asset = |in_service_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset {
|
||||
description: "Late".into(),
|
||||
purchase_date,
|
||||
in_service_date,
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months,
|
||||
@@ -267,11 +267,11 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
.ledger
|
||||
.replay_register_asset(asset(d(2026, 1, 1), 12))
|
||||
.unwrap();
|
||||
let fix = |purchase_date: NaiveDate| ledger_core::AssetCorrection {
|
||||
let fix = |in_service_date: NaiveDate| ledger_core::AssetCorrection {
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 60,
|
||||
purchase_date,
|
||||
in_service_date,
|
||||
};
|
||||
assert_eq!(
|
||||
b.ledger.correct_asset(id, fix(d(9999, 1, 1))).unwrap_err(),
|
||||
@@ -280,7 +280,10 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
b.ledger
|
||||
.replay_correct_asset(id, fix(d(9999, 1, 1)))
|
||||
.unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().purchase_date, d(9999, 1, 1));
|
||||
assert_eq!(
|
||||
b.ledger.get_asset(id).unwrap().in_service_date,
|
||||
d(9999, 1, 1)
|
||||
);
|
||||
// A disposal dated past the calendar.
|
||||
let proceeds = usd(Decimal::ZERO);
|
||||
assert_eq!(
|
||||
@@ -467,18 +470,36 @@ fn amounts_larger_than_a_line_are_refused_everywhere() {
|
||||
ledger_core::payout_plan(&payout(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT), b.ledger.chart())
|
||||
.is_ok()
|
||||
);
|
||||
// A contributor's basis.
|
||||
let basis = |b: Decimal| ledger_core::TaxBasis {
|
||||
rule: ledger_core::TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(b),
|
||||
// A contributor's basis and a value at contribution.
|
||||
let basis = |b: Decimal, v: Decimal| {
|
||||
ledger_core::AssetBasis::contribution(ledger_core::BasisRule::Carryover723, b, v)
|
||||
};
|
||||
assert!(basis(big).check().is_err());
|
||||
assert!(basis(MAX_LINE_AMOUNT).check().is_ok());
|
||||
// On replay: every journaled basis passed check_journaled.
|
||||
assert!(basis(big, Decimal::ONE).check().is_err());
|
||||
assert!(basis(Decimal::ONE, big).check().is_err());
|
||||
assert!(basis(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT).check().is_ok());
|
||||
// On replay: every journaled basis passed check_journaled (a pre-P3
|
||||
// one check_legacy_journaled).
|
||||
let cents_max = Decimal::MAX.round_dp(2) / Decimal::ONE_HUNDRED;
|
||||
assert!(basis(big.round_dp(2)).check_journaled().is_ok());
|
||||
assert!(basis(cents_max.round_dp(2)).check_journaled().is_ok());
|
||||
assert!(basis(-Decimal::ONE).check_journaled().is_err());
|
||||
assert!(
|
||||
basis(big.round_dp(2), big.round_dp(2))
|
||||
.check_journaled()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
basis(cents_max.round_dp(2), Decimal::ONE)
|
||||
.check_journaled()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
basis(-Decimal::ONE, Decimal::ONE)
|
||||
.check_journaled()
|
||||
.is_err()
|
||||
);
|
||||
let mut legacy = basis(big.round_dp(2), Decimal::ONE);
|
||||
legacy.fmv_at_contribution = None;
|
||||
assert!(legacy.check_legacy_journaled().is_ok());
|
||||
legacy.contributor_adjusted_basis = Some(-Decimal::ONE);
|
||||
assert!(legacy.check_legacy_journaled().is_err());
|
||||
// A party's share of an entry.
|
||||
let share = |a: Decimal| {
|
||||
vec![ledger_core::PartyShare {
|
||||
@@ -508,16 +529,19 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
|
||||
// Decimal's largest is a whole number of cents, as every version
|
||||
// required of a basis and a share.
|
||||
let cents_max = Decimal::MAX;
|
||||
let basis = ledger_core::TaxBasis {
|
||||
rule: ledger_core::TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(cents_max),
|
||||
let basis = ledger_core::AssetBasis {
|
||||
rule: ledger_core::BasisRule::Carryover723,
|
||||
contributor_adjusted_basis: Some(cents_max),
|
||||
fmv_at_contribution: None,
|
||||
sec704c_method: None,
|
||||
source: None,
|
||||
};
|
||||
for _ in 0..2 {
|
||||
let id = b
|
||||
.ledger
|
||||
.register_asset(ledger_core::NewAsset {
|
||||
description: "Laptop".into(),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
in_service_date: d(2026, 1, 1),
|
||||
cost: usd(Decimal::new(240000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 24,
|
||||
@@ -526,12 +550,16 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
|
||||
expense_account: depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
let mut new = basis.clone();
|
||||
new.fmv_at_contribution = Some(Decimal::new(240000, 2));
|
||||
assert!(matches!(
|
||||
b.ledger.set_asset_tax_basis(id, basis),
|
||||
b.ledger.set_asset_basis(id, new),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
b.ledger.replay_set_asset_tax_basis(id, basis).unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().tax, Some(basis));
|
||||
b.ledger
|
||||
.replay_set_asset_tax_basis(id, basis.rule, basis.contributor_adjusted_basis)
|
||||
.unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().basis, Some(basis.clone()));
|
||||
}
|
||||
assert!(matches!(
|
||||
ledger_core::fixed_asset_register(&b.ledger, d(2026, 12, 31)),
|
||||
@@ -630,7 +658,7 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
let laptop = || ledger_core::NewAsset {
|
||||
description: "Laptop".into(),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
in_service_date: d(2026, 1, 1),
|
||||
cost: usd(Decimal::new(240000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 24,
|
||||
@@ -672,8 +700,11 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
||||
b.ledger.get_asset(old).unwrap().status,
|
||||
ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX
|
||||
));
|
||||
// Replay keeps what every version checked: no disposal twice, none
|
||||
// before the purchase, no negative proceeds.
|
||||
// Replay keeps what every version checked: no disposal twice, no
|
||||
// negative proceeds. The date floor (the acquired date, P3) is a
|
||||
// command's: replay accepts a disposal before it, which every earlier
|
||||
// version's commands refused anyway (rules.md B-28: replay accepts
|
||||
// more, never less).
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE))
|
||||
@@ -681,16 +712,19 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
||||
LedgerError::AssetAlreadyDisposed(old)
|
||||
);
|
||||
let early = b.ledger.register_asset(laptop()).unwrap();
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)),
|
||||
Err(LedgerError::NegativeProceeds(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)),
|
||||
Err(LedgerError::DisposalBeforeAcquisition { .. })
|
||||
));
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The bounds are the line limit exactly: a new draft line (base or
|
||||
@@ -808,12 +842,12 @@ fn new_asset(
|
||||
accounts: (AccountId, AccountId, AccountId),
|
||||
cost: &str,
|
||||
salvage: &str,
|
||||
purchase_date: NaiveDate,
|
||||
in_service_date: NaiveDate,
|
||||
life: u32,
|
||||
) -> ledger_core::NewAsset {
|
||||
ledger_core::NewAsset {
|
||||
description: "Machine".into(),
|
||||
purchase_date,
|
||||
in_service_date,
|
||||
cost: usd(cost.parse().unwrap()),
|
||||
salvage_value: usd(salvage.parse().unwrap()),
|
||||
useful_life_months: life,
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Books {
|
||||
self.l
|
||||
.register_asset(NewAsset {
|
||||
description: format!("Asset on {cost}"),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
in_service_date: d(2026, 1, 1),
|
||||
cost: Money::new(x(amount), Currency::USD),
|
||||
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
|
||||
useful_life_months: 36,
|
||||
|
||||
@@ -46,7 +46,7 @@ fn depreciation_reaches_chronos_last_month() {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: "edge".into(),
|
||||
purchase_date: d(y, 1, 15),
|
||||
in_service_date: d(y, 1, 15),
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 11,
|
||||
@@ -83,11 +83,11 @@ fn depreciation_reaches_chronos_last_month() {
|
||||
assert_eq!(want, Decimal::new(118592, 2));
|
||||
assert_eq!(dec.last().unwrap().accumulated, want);
|
||||
assert_eq!(
|
||||
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
||||
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||
Decimal::new(120000, 2)
|
||||
);
|
||||
assert_eq!(
|
||||
ledger_core::book_value(a, NaiveDate::MAX).unwrap(),
|
||||
ledger_core::scheduled_book_value(a, NaiveDate::MAX).unwrap(),
|
||||
Decimal::ZERO
|
||||
);
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn a_life_past_9999_trues_up_to_the_cent() {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: "far".into(),
|
||||
purchase_date: start,
|
||||
in_service_date: start,
|
||||
cost: usd(Decimal::new(100_000, 2)),
|
||||
salvage_value: usd(Decimal::new(1, 2)),
|
||||
useful_life_months: months,
|
||||
@@ -172,7 +172,7 @@ fn february_is_prorated_by_chronos_calendar_in_every_year() {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: format!("{year}"),
|
||||
purchase_date: d(year, 2, 1),
|
||||
in_service_date: d(year, 2, 1),
|
||||
cost: usd(Decimal::from(2900)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 2,
|
||||
|
||||
@@ -1523,7 +1523,7 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
|
||||
);
|
||||
co.l.register_asset(NewAsset {
|
||||
description: "Laptop".into(),
|
||||
purchase_date: d(2026, 2, 1),
|
||||
in_service_date: d(2026, 2, 1),
|
||||
cost: usd(120000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 12,
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
//! Spec-derived tests for an asset's federal tax basis (rules.md B-25):
|
||||
//! `TaxBasisRule`, `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`,
|
||||
//! `Asset::tax_basis`, `Asset::built_in_gain`, and the tax fields of
|
||||
//! `reports::fixed_asset_register`.
|
||||
//!
|
||||
//! Every expected value comes from the written specification: the doc
|
||||
//! comments and INVARIANTS/SCENARIOS blocks on those items, and the
|
||||
//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost
|
||||
//! basis (IRC §1012(a)), carryover of the contributor's adjusted basis
|
||||
//! (IRC §723), the lesser of fair market value and adjusted basis on a
|
||||
//! conversion from personal use (Treas. Reg. §1.168(i)-4(b)(1)), and
|
||||
//! built-in gain or loss as fair market value less the contributor's basis
|
||||
//! (IRC §704(c)). Amounts are worked by hand.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
||||
TaxBasis, TaxBasisRule, fixed_asset_register,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).expect("decimal literal")
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(d(s), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
|
||||
}
|
||||
|
||||
struct Accts {
|
||||
equipment: AccountId,
|
||||
accum: AccountId,
|
||||
depr_expense: AccountId,
|
||||
}
|
||||
|
||||
/// A USD ledger with a fixed-asset chart, funded with $50,000 cash.
|
||||
fn fixture() -> (Ledger, Accts) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut acct = |code: &str, name: &str, ty: AccountType| {
|
||||
l.add_account(code, name, ty, None, false).unwrap()
|
||||
};
|
||||
let cash = acct("1000", "Cash", AccountType::Asset);
|
||||
let equipment = acct("1500", "Equipment", AccountType::Asset);
|
||||
let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset);
|
||||
let capital = acct("3000", "Owner Capital", AccountType::Equity);
|
||||
let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense);
|
||||
let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap();
|
||||
l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap();
|
||||
l.add_line(e, capital, Side::Credit, usd("50000.00"))
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
(
|
||||
l,
|
||||
Accts {
|
||||
equipment,
|
||||
accum,
|
||||
depr_expense,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId {
|
||||
l.register_asset(NewAsset {
|
||||
description: description.to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd(cost),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 36,
|
||||
cost_account: a.equipment,
|
||||
accum_depr_account: a.accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn tb(rule: TaxBasisRule, basis: Option<&str>) -> TaxBasis {
|
||||
TaxBasis {
|
||||
rule,
|
||||
contributor_basis: basis.map(d),
|
||||
}
|
||||
}
|
||||
|
||||
/// A ledger with one asset of book cost (fair market value) `cost`.
|
||||
fn one(cost: &str) -> (Ledger, AssetId) {
|
||||
let (mut l, a) = fixture();
|
||||
let id = register(&mut l, &a, "Laptop", cost);
|
||||
(l, id)
|
||||
}
|
||||
|
||||
fn set(l: &mut Ledger, id: AssetId, rule: TaxBasisRule, basis: Option<&str>) {
|
||||
l.set_asset_tax_basis(id, tb(rule, basis)).unwrap();
|
||||
}
|
||||
|
||||
const RULES: [TaxBasisRule; 3] = [
|
||||
TaxBasisRule::Cost,
|
||||
TaxBasisRule::Carryover,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- TaxBasisRule
|
||||
|
||||
/// TaxBasisRule::key: the wire names `cost`, `carryover`,
|
||||
/// `personal_use_conversion`.
|
||||
#[test]
|
||||
fn rule_keys_are_the_documented_wire_names() {
|
||||
assert_eq!(TaxBasisRule::Cost.key(), "cost");
|
||||
assert_eq!(TaxBasisRule::Carryover.key(), "carryover");
|
||||
assert_eq!(
|
||||
TaxBasisRule::PersonalUseConversion.key(),
|
||||
"personal_use_conversion"
|
||||
);
|
||||
}
|
||||
|
||||
/// TaxBasisRule::parse: the rule named `key`; round-trips with key().
|
||||
#[test]
|
||||
fn rule_parse_round_trips_with_key() {
|
||||
for r in RULES {
|
||||
assert_eq!(TaxBasisRule::parse(r.key()), Some(r));
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything other than a documented wire name is no rule.
|
||||
#[test]
|
||||
fn rule_parse_refuses_other_names() {
|
||||
for bad in [
|
||||
"",
|
||||
"Cost",
|
||||
"COST",
|
||||
" cost",
|
||||
"cost ",
|
||||
"carry_over",
|
||||
"personal-use-conversion",
|
||||
"PersonalUseConversion",
|
||||
"fmv",
|
||||
] {
|
||||
assert_eq!(TaxBasisRule::parse(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The serde names are snake_case, the same as the wire names.
|
||||
#[test]
|
||||
fn rule_serde_names_are_the_wire_names() {
|
||||
for r in RULES {
|
||||
let j = serde_json::to_value(r).unwrap();
|
||||
assert_eq!(j, serde_json::Value::String(r.key().to_string()));
|
||||
let back: TaxBasisRule = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back, r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- set / check
|
||||
|
||||
/// SCENARIO 1: an unknown asset is refused with UnknownAsset.
|
||||
#[test]
|
||||
fn an_unknown_asset_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
let unknown = id + 99;
|
||||
for t in [
|
||||
tb(TaxBasisRule::Cost, None),
|
||||
tb(TaxBasisRule::Carryover, Some("500.00")),
|
||||
] {
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(unknown, t),
|
||||
Err(LedgerError::UnknownAsset(x)) if x == unknown
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(unknown, t),
|
||||
Err(LedgerError::UnknownAsset(x)) if x == unknown
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Cost takes no contributor basis.
|
||||
#[test]
|
||||
fn cost_with_a_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for b in ["0.00", "500.00", "1000.00"] {
|
||||
let t = tb(TaxBasisRule::Cost, Some(b));
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
assert_eq!(l.get_asset(id).unwrap().tax, None);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Carryover and PersonalUseConversion require one.
|
||||
#[test]
|
||||
fn a_contribution_without_a_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
let t = tb(r, None);
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a contributor basis is non-negative.
|
||||
#[test]
|
||||
fn a_negative_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
for b in ["-0.01", "-1", "-500.00"] {
|
||||
let t = tb(r, Some(b));
|
||||
assert!(
|
||||
matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
),
|
||||
"{r:?} {b}"
|
||||
);
|
||||
assert!(l.check_asset_tax_basis(id, t).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 2: in cents at most.
|
||||
#[test]
|
||||
fn more_than_two_decimal_places_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
for b in ["500.001", "0.005", "1.2345"] {
|
||||
let t = tb(r, Some(b));
|
||||
assert!(
|
||||
matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
),
|
||||
"{r:?} {b}"
|
||||
);
|
||||
assert!(l.check_asset_tax_basis(id, t).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// Valid records: zero is non-negative; whole dollars and one decimal
|
||||
/// place are "in cents at most". check agrees with set, and a valid set
|
||||
/// changes nothing but the asset's tax record (book figures don't change).
|
||||
#[test]
|
||||
fn valid_records_are_accepted_and_book_figures_stay() {
|
||||
for (r, b) in [
|
||||
(TaxBasisRule::Cost, None),
|
||||
(TaxBasisRule::Carryover, Some("0")),
|
||||
(TaxBasisRule::Carryover, Some("0.00")),
|
||||
(TaxBasisRule::Carryover, Some("750")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("750.5")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("750.50")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("2500.00")),
|
||||
] {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let t = tb(r, b);
|
||||
l.check_asset_tax_basis(id, t).unwrap();
|
||||
let before = l.get_asset(id).unwrap().clone();
|
||||
l.set_asset_tax_basis(id, t).unwrap();
|
||||
let after = l.get_asset(id).unwrap();
|
||||
assert_eq!(after.tax, Some(t), "{r:?} {b:?}");
|
||||
// Book figures don't change.
|
||||
let mut same = after.clone();
|
||||
same.tax = before.tax;
|
||||
assert_eq!(same, before, "{r:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// SCENARIO 2: set again, the new record replaces the old.
|
||||
#[test]
|
||||
fn setting_again_replaces() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("1000.00")));
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some("400.00"));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, Some(tb(TaxBasisRule::Carryover, Some("400.00"))));
|
||||
assert_eq!(a.tax_basis(), Some(d("400.00")));
|
||||
set(
|
||||
&mut l,
|
||||
id,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("1200.00"),
|
||||
);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(
|
||||
a.tax,
|
||||
Some(tb(TaxBasisRule::PersonalUseConversion, Some("1200.00")))
|
||||
);
|
||||
assert_eq!(a.tax_basis(), Some(d("1000.00")));
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, Some(tb(TaxBasisRule::Cost, None)));
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a refusal after a valid record leaves that record.
|
||||
#[test]
|
||||
fn a_refusal_keeps_the_earlier_record() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some("300.00"));
|
||||
let before = l.clone();
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Cost, Some("1.00")))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, None))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("-1.00")))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("1.001")))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(l, before);
|
||||
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("300.00")));
|
||||
}
|
||||
|
||||
/// Setting one asset's record leaves the others alone.
|
||||
#[test]
|
||||
fn a_record_belongs_to_one_asset() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1000.00");
|
||||
let y = register(&mut l, &a, "Desk", "400.00");
|
||||
set(&mut l, x, TaxBasisRule::Carryover, Some("800.00"));
|
||||
assert_eq!(l.get_asset(y).unwrap().tax, None);
|
||||
assert_eq!(l.get_asset(y).unwrap().tax_basis(), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tax_basis
|
||||
|
||||
/// Not recorded: None (and no built-in gain).
|
||||
#[test]
|
||||
fn no_record_means_no_tax_basis() {
|
||||
let (l, id) = one("1000.00");
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, None);
|
||||
assert_eq!(a.tax_basis(), None);
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Cost: the book cost (IRC §1012(a)).
|
||||
#[test]
|
||||
fn cost_basis_is_the_book_cost() {
|
||||
let (mut l, id) = one("1234.56");
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d("1234.56")));
|
||||
// A purchase has no built-in gain.
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: Carryover: the contributor's adjusted basis (IRC §723),
|
||||
/// whether above or below fair market value.
|
||||
#[test]
|
||||
fn carryover_basis_is_the_contributors_basis() {
|
||||
for (fmv, basis, gain) in [
|
||||
("1000.00", "600.00", "400.00"), // built-in gain
|
||||
("1000.00", "1500.00", "-500.00"), // built-in loss
|
||||
("1000.00", "1000.00", "0.00"), // boundary
|
||||
("1000.00", "0.00", "1000.00"), // fully depreciated
|
||||
] {
|
||||
let (mut l, id) = one(fmv);
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some(basis));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d(basis)), "{fmv} {basis}");
|
||||
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 3: PersonalUseConversion: min(book cost, contributor's basis)
|
||||
/// (Treas. Reg. §1.168(i)-4(b)(1): "the lesser of its fair market value or
|
||||
/// its adjusted depreciable basis").
|
||||
#[test]
|
||||
fn personal_use_conversion_is_the_lesser_of_value_and_basis() {
|
||||
for (fmv, basis, tax, gain) in [
|
||||
// Paid 2,000, worth 1,200 now: basis 1,200; built-in loss −800.
|
||||
("1200.00", "2000.00", "1200.00", "-800.00"),
|
||||
// Paid 300, worth 450: basis 300; built-in gain 150.
|
||||
("450.00", "300.00", "300.00", "150.00"),
|
||||
// Boundary: value equals basis.
|
||||
("999.99", "999.99", "999.99", "0.00"),
|
||||
// A cent either side of the boundary.
|
||||
("1000.00", "1000.01", "1000.00", "-0.01"),
|
||||
("1000.00", "999.99", "999.99", "0.01"),
|
||||
] {
|
||||
let (mut l, id) = one(fmv);
|
||||
set(&mut l, id, TaxBasisRule::PersonalUseConversion, Some(basis));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d(tax)), "{fmv} {basis}");
|
||||
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// built_in_gain: FMV (book cost) less the contributor's basis; positive
|
||||
/// for a gain, negative for a loss, zero at FMV == basis, regardless of
|
||||
/// which contribution rule (the definition does not depend on the rule).
|
||||
#[test]
|
||||
fn built_in_gain_sign_and_boundary() {
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
let (mut l, id) = one("800.00");
|
||||
set(&mut l, id, r, Some("500.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert_eq!(g, d("300.00"));
|
||||
assert!(g > Decimal::ZERO, "{r:?}: FMV > basis is a gain");
|
||||
|
||||
set(&mut l, id, r, Some("1100.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert_eq!(g, d("-300.00"));
|
||||
assert!(g < Decimal::ZERO, "{r:?}: basis > FMV is a loss");
|
||||
|
||||
set(&mut l, id, r, Some("800.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert!(g.is_zero(), "{r:?}: FMV == basis is neither");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- register
|
||||
|
||||
struct RegisterBooks {
|
||||
l: Ledger,
|
||||
bought: AssetId,
|
||||
carried: AssetId,
|
||||
converted: AssetId,
|
||||
unrecorded: AssetId,
|
||||
sold: AssetId,
|
||||
}
|
||||
|
||||
/// Five assets: a purchase at cost, a carryover contribution, a
|
||||
/// personal-use conversion, one with no tax basis, and one disposed (with
|
||||
/// a tax basis, which the totals must leave out).
|
||||
fn register_books() -> RegisterBooks {
|
||||
let (mut l, a) = fixture();
|
||||
let bought = register(&mut l, &a, "Server", "3000.00");
|
||||
let carried = register(&mut l, &a, "Printer", "500.00");
|
||||
let converted = register(&mut l, &a, "Laptop", "1200.00");
|
||||
let unrecorded = register(&mut l, &a, "Desk", "700.00");
|
||||
let sold = register(&mut l, &a, "Old monitor", "360.00");
|
||||
set(&mut l, bought, TaxBasisRule::Cost, None);
|
||||
set(&mut l, carried, TaxBasisRule::Carryover, Some("650.25"));
|
||||
set(
|
||||
&mut l,
|
||||
converted,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("2000.00"),
|
||||
);
|
||||
set(&mut l, sold, TaxBasisRule::Cost, None);
|
||||
l.dispose_asset(sold, date(2026, 3, 31), usd("100.00"))
|
||||
.unwrap();
|
||||
RegisterBooks {
|
||||
l,
|
||||
bought,
|
||||
carried,
|
||||
converted,
|
||||
unrecorded,
|
||||
sold,
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 5: each row's tax fields are the asset's.
|
||||
#[test]
|
||||
fn register_rows_carry_the_assets_tax_fields() {
|
||||
let b = register_books();
|
||||
let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.rows.len(), 5);
|
||||
for row in &r.rows {
|
||||
let a = b.l.get_asset(row.asset_id).unwrap();
|
||||
assert_eq!(row.tax_rule, a.tax.map(|t| t.rule));
|
||||
assert_eq!(
|
||||
row.contributor_basis,
|
||||
a.tax.and_then(|t| t.contributor_basis)
|
||||
);
|
||||
assert_eq!(row.tax_basis, a.tax_basis());
|
||||
assert_eq!(row.built_in_gain, a.built_in_gain());
|
||||
}
|
||||
let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap();
|
||||
// By hand.
|
||||
let x = row(b.bought);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
|
||||
assert_eq!(x.contributor_basis, None);
|
||||
assert_eq!(x.tax_basis, Some(d("3000.00")));
|
||||
assert_eq!(x.built_in_gain, None);
|
||||
let x = row(b.carried);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Carryover));
|
||||
assert_eq!(x.contributor_basis, Some(d("650.25")));
|
||||
assert_eq!(x.tax_basis, Some(d("650.25")));
|
||||
assert_eq!(x.built_in_gain, Some(d("-150.25")));
|
||||
let x = row(b.converted);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::PersonalUseConversion));
|
||||
assert_eq!(x.contributor_basis, Some(d("2000.00")));
|
||||
assert_eq!(x.tax_basis, Some(d("1200.00")));
|
||||
assert_eq!(x.built_in_gain, Some(d("-800.00")));
|
||||
let x = row(b.unrecorded);
|
||||
assert_eq!(x.tax_rule, None);
|
||||
assert_eq!(x.contributor_basis, None);
|
||||
assert_eq!(x.tax_basis, None);
|
||||
assert_eq!(x.built_in_gain, None);
|
||||
// A disposed asset's row still shows its record.
|
||||
let x = row(b.sold);
|
||||
assert!(x.disposed.is_some());
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
|
||||
assert_eq!(x.tax_basis, Some(d("360.00")));
|
||||
}
|
||||
|
||||
/// INVARIANT 3: total_tax_basis sums held assets with a basis, exactly;
|
||||
/// missing_tax_basis counts held ones without; disposed excluded.
|
||||
#[test]
|
||||
fn register_tax_totals_cover_held_assets_only() {
|
||||
let b = register_books();
|
||||
let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap();
|
||||
// 3000.00 + 650.25 + 1200.00 (the disposed 360.00 left out).
|
||||
assert_eq!(r.total_tax_basis, d("4850.25"));
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
// Before the disposal date the old monitor is still held.
|
||||
let r = fixed_asset_register(&b.l, date(2026, 2, 28)).unwrap();
|
||||
assert_eq!(r.total_tax_basis, d("5210.25"));
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
}
|
||||
|
||||
/// A disposed asset without a tax basis isn't counted as missing.
|
||||
#[test]
|
||||
fn a_disposed_asset_without_a_basis_is_not_missing() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1000.00");
|
||||
let y = register(&mut l, &a, "Desk", "400.00");
|
||||
l.dispose_asset(y, date(2026, 3, 31), usd("0.00")).unwrap();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
assert_eq!(r.total_tax_basis, Decimal::ZERO);
|
||||
set(&mut l, x, TaxBasisRule::Carryover, Some("900.00"));
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
assert_eq!(r.total_tax_basis, d("900.00"));
|
||||
}
|
||||
|
||||
/// No assets: zero total, none missing.
|
||||
#[test]
|
||||
fn an_empty_register_has_no_tax_basis() {
|
||||
let (l, _) = fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert!(r.rows.is_empty());
|
||||
assert_eq!(r.total_tax_basis, Decimal::ZERO);
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
}
|
||||
|
||||
/// Recording tax basis leaves the book totals alone.
|
||||
#[test]
|
||||
fn tax_basis_does_not_move_book_totals() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1200.00");
|
||||
let before = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
set(
|
||||
&mut l,
|
||||
x,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("2000.00"),
|
||||
);
|
||||
let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(after.total_cost, before.total_cost);
|
||||
assert_eq!(after.total_accumulated, before.total_accumulated);
|
||||
assert_eq!(after.total_book_value, before.total_book_value);
|
||||
assert_eq!(after.ledger_cost, before.ledger_cost);
|
||||
assert_eq!(after.ledger_accumulated, before.ledger_accumulated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- proptest
|
||||
|
||||
fn cents() -> impl Strategy<Value = Decimal> {
|
||||
(0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
fn positive_cents() -> impl Strategy<Value = Decimal> {
|
||||
(1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// Asset::tax_basis INVARIANTS 1–3 and built_in_gain over random
|
||||
/// costs, bases, and rules; register totals agree.
|
||||
#[test]
|
||||
fn prop_tax_basis_follows_its_rule(
|
||||
assets in proptest::collection::vec((positive_cents(), cents(), 0usize..4), 1..8)
|
||||
) {
|
||||
let (mut l, a) = fixture();
|
||||
let mut expected_total = Decimal::ZERO;
|
||||
let mut expected_missing = 0usize;
|
||||
for (i, (cost, basis, rule)) in assets.iter().enumerate() {
|
||||
let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string());
|
||||
let want = match rule {
|
||||
0 => {
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
Some((*cost, None))
|
||||
}
|
||||
1 => {
|
||||
l.set_asset_tax_basis(id, TaxBasis {
|
||||
rule: TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(*basis),
|
||||
}).unwrap();
|
||||
Some((*basis, Some(*cost - *basis)))
|
||||
}
|
||||
2 => {
|
||||
l.set_asset_tax_basis(id, TaxBasis {
|
||||
rule: TaxBasisRule::PersonalUseConversion,
|
||||
contributor_basis: Some(*basis),
|
||||
}).unwrap();
|
||||
Some((if cost < basis { *cost } else { *basis }, Some(*cost - *basis)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let asset = l.get_asset(id).unwrap();
|
||||
match want {
|
||||
Some((tax, gain)) => {
|
||||
prop_assert_eq!(asset.tax_basis(), Some(tax));
|
||||
prop_assert_eq!(asset.built_in_gain(), gain);
|
||||
expected_total += tax;
|
||||
}
|
||||
None => {
|
||||
prop_assert_eq!(asset.tax_basis(), None);
|
||||
expected_missing += 1;
|
||||
}
|
||||
}
|
||||
if let Some(t) = asset.tax_basis() {
|
||||
// Personal-use conversion never exceeds fair market value.
|
||||
if *rule == 2 {
|
||||
prop_assert!(t <= *cost);
|
||||
prop_assert!(t <= *basis);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
prop_assert_eq!(r.total_tax_basis, expected_total);
|
||||
prop_assert_eq!(r.missing_tax_basis, expected_missing);
|
||||
}
|
||||
|
||||
/// check agrees with set, and a refused record changes nothing.
|
||||
#[test]
|
||||
fn prop_check_agrees_with_set(
|
||||
cents in -1_000_000i64..1_000_000,
|
||||
scale in 0u32..5,
|
||||
rule in 0usize..3,
|
||||
some in any::<bool>(),
|
||||
) {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let basis = some.then(|| Decimal::new(cents, scale));
|
||||
let t = TaxBasis { rule: RULES[rule], contributor_basis: basis };
|
||||
let before = l.clone();
|
||||
let checked = l.check_asset_tax_basis(id, t);
|
||||
let done = l.set_asset_tax_basis(id, t);
|
||||
prop_assert_eq!(checked.is_ok(), done.is_ok());
|
||||
// The spec, independently: INVARIANTS 1 and 2.
|
||||
let valid = match (RULES[rule], basis) {
|
||||
(TaxBasisRule::Cost, None) => true,
|
||||
(TaxBasisRule::Cost, Some(_)) => false,
|
||||
(_, None) => false,
|
||||
(_, Some(b)) => b >= Decimal::ZERO && b.normalize().scale() <= 2,
|
||||
};
|
||||
prop_assert_eq!(done.is_ok(), valid, "{:?} {:?}", RULES[rule], basis);
|
||||
if done.is_err() {
|
||||
prop_assert_eq!(&l, &before);
|
||||
} else {
|
||||
prop_assert_eq!(l.get_asset(id).unwrap().tax, Some(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1210,7 +1210,7 @@ fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
|
||||
.unwrap();
|
||||
l.register_asset(ledger_core::NewAsset {
|
||||
description: "Laptop".to_string(),
|
||||
purchase_date: d(1, 10),
|
||||
in_service_date: d(1, 10),
|
||||
cost: usd(1_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 10,
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Spec tests for the K-1 worksheet's tax-basis capital
|
||||
//! (`ledger_core::tax_capital`, rules.md B-36): Item L on the tax basis
|
||||
//! beside the book figures, every figure the books can't know left open
|
||||
//! and named, and the reconciliation from book to tax capital.
|
||||
//!
|
||||
//! Expected values are worked by hand from the Form 1065 instructions'
|
||||
//! Item L (`docs/spec/sources/irs-partnerships.md`): capital contributed is
|
||||
//! "the amount of cash plus the adjusted tax basis of all property
|
||||
//! contributed"; withdrawals and distributions "the amount of cash plus
|
||||
//! the adjusted tax basis of all property distributed". Names and figures
|
||||
//! are invented.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::MemberCapital;
|
||||
use ledger_core::tax_capital::{Open, Reconciliation, tax_basis_capital};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money,
|
||||
NewAsset, Sec704cMethod, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(d(s), Currency::USD)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
cash: AccountId,
|
||||
prepaid: AccountId,
|
||||
equipment: AccountId,
|
||||
accum: AccountId,
|
||||
tobin: AccountId,
|
||||
morgan: AccountId,
|
||||
revenue: AccountId,
|
||||
depreciation: AccountId,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = add("1000", "Bank", AccountType::Asset);
|
||||
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
|
||||
let equipment = add("1500", "Equipment", AccountType::Asset);
|
||||
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
|
||||
let tobin = add("3000", "Capital, Tobin Harlow", AccountType::Equity);
|
||||
let morgan = add("3001", "Capital, Morgan Ellis", AccountType::Equity);
|
||||
let revenue = add("4000", "Revenue", AccountType::Revenue);
|
||||
let depreciation = add("6200", "Depreciation", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
cash,
|
||||
prepaid,
|
||||
equipment,
|
||||
accum,
|
||||
tobin,
|
||||
morgan,
|
||||
revenue,
|
||||
depreciation,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.try_create_entry(on, "entry").unwrap();
|
||||
for (a, side, amt) in lines {
|
||||
l.add_line(e, *a, *side, usd(amt)).unwrap();
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn members(b: &Books) -> [MemberCapital; 2] {
|
||||
[
|
||||
MemberCapital {
|
||||
name: "Tobin Harlow".into(),
|
||||
capital_account: b.tobin,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
MemberCapital {
|
||||
name: "Morgan Ellis".into(),
|
||||
capital_account: b.morgan,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// A device two members contribute, in service 2026-01-01 over 24 months.
|
||||
fn device(b: &mut Books, name: &str, fmv: &str, adjusted: &str, entry: EntryId) -> AssetId {
|
||||
let id =
|
||||
b.l.register_asset(NewAsset {
|
||||
description: name.into(),
|
||||
in_service_date: date(2026, 1, 1),
|
||||
cost: usd(fmv),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 24,
|
||||
cost_account: b.equipment,
|
||||
accum_depr_account: b.accum,
|
||||
expense_account: b.depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
b.l.set_asset_acquisition(id, entry).unwrap();
|
||||
let mut basis = AssetBasis::contribution(BasisRule::PersonalUseConversion, d(adjusted), d(fmv));
|
||||
basis.sec704c_method = Some(Sec704cMethod::Traditional);
|
||||
b.l.set_asset_basis(id, basis).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Every month's run Jan..=Dec 2026, as the schedule gives it.
|
||||
fn a_year_of_runs(b: &mut Books, id: AssetId) {
|
||||
let a = b.l.get_asset(id).unwrap().clone();
|
||||
for l in ledger_core::depreciation_schedule(&a, date(2026, 12, 31)).unwrap() {
|
||||
let (y, m) = (
|
||||
l.period[..4].parse().unwrap(),
|
||||
l.period[5..].parse().unwrap(),
|
||||
);
|
||||
let end = date(y, m, 1)
|
||||
.checked_add_months(chrono::Months::new(1))
|
||||
.unwrap()
|
||||
.pred_opt()
|
||||
.unwrap();
|
||||
let amt = l.amount.to_string();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
end,
|
||||
&[
|
||||
(b.depreciation, Side::Debit, &amt),
|
||||
(b.accum, Side::Credit, &amt),
|
||||
],
|
||||
);
|
||||
b.l.check_depreciation_run(id, &l.period, e, l.amount)
|
||||
.unwrap();
|
||||
b.l.record_depreciation_run(id, &l.period, e, l.amount)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The year, by hand. Tobin Harlow and Morgan Ellis, 50/50:
|
||||
/// - 2026-01-01 cash in: Dr 1000 2,000.00 / Cr 3000 1,000.00, Cr 3001
|
||||
/// 1,000.00;
|
||||
/// - 2026-01-01 two devices in, jointly, from personal use: a laptop worth
|
||||
/// 1,440.00 (adjusted basis 1,688.40: built-in loss 248.40) and a camera
|
||||
/// worth 1,300.00 (basis 1,080.00: built-in gain 220.00); Dr 1500
|
||||
/// 2,740.00 / Cr 3000 1,370.00, Cr 3001 1,370.00;
|
||||
/// - revenue 600.00; a year of depreciation, 24-month lives: 1,440/2 +
|
||||
/// 1,300/2 = 720.00 + 650.00 = 1,370.00; net income 600.00 − 1,370.00 =
|
||||
/// −770.00, −385.00 each;
|
||||
/// - 2026-12-15 cash out: Dr 3000 300.00, Dr 3001 300.00 / Cr 1000 600.00.
|
||||
///
|
||||
/// Book: 1,000.00 + 1,370.00 − 385.00 − 300.00 = 1,685.00 each.
|
||||
/// Tax: property at §723 basis, (1,688.40 + 1,080.00) / 2 = 1,384.20 each
|
||||
/// (book 1,370.00 less the built-in gain share (−248.40 + 220.00) / 2 =
|
||||
/// −14.20); the year's income is open (tax depreciation is the CPA's), so
|
||||
/// the ending the books know is 1,000.00 + 1,384.20 − 385.00 − 300.00 =
|
||||
/// 1,699.20; book less tax = −14.20, the built-in gain share.
|
||||
fn the_year() -> (Books, EntryId, AssetId, AssetId) {
|
||||
let mut b = books();
|
||||
let (cash, tobin, morgan, equipment) = (b.cash, b.tobin, b.morgan, b.equipment);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "2000.00"),
|
||||
(tobin, Side::Credit, "1000.00"),
|
||||
(morgan, Side::Credit, "1000.00"),
|
||||
],
|
||||
);
|
||||
let devices = post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 1),
|
||||
&[
|
||||
(equipment, Side::Debit, "1440.00"),
|
||||
(equipment, Side::Debit, "1300.00"),
|
||||
(tobin, Side::Credit, "1370.00"),
|
||||
(morgan, Side::Credit, "1370.00"),
|
||||
],
|
||||
);
|
||||
let laptop = device(&mut b, "Laptop", "1440.00", "1688.40", devices);
|
||||
let camera = device(&mut b, "Camera", "1300.00", "1080.00", devices);
|
||||
let revenue = b.revenue;
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 2, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "600.00"),
|
||||
(revenue, Side::Credit, "600.00"),
|
||||
],
|
||||
);
|
||||
a_year_of_runs(&mut b, laptop);
|
||||
a_year_of_runs(&mut b, camera);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 12, 15),
|
||||
&[
|
||||
(tobin, Side::Debit, "300.00"),
|
||||
(morgan, Side::Debit, "300.00"),
|
||||
(cash, Side::Credit, "600.00"),
|
||||
],
|
||||
);
|
||||
(b, devices, laptop, camera)
|
||||
}
|
||||
|
||||
/// Item L on the tax basis, per member, for the year (B-36): a cash
|
||||
/// contribution, a contribution of property, a distribution, a year of
|
||||
/// depreciation.
|
||||
#[test]
|
||||
fn tax_basis_capital_per_member() {
|
||||
let (b, _, _, _) = the_year();
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[b.cash],
|
||||
)
|
||||
.unwrap();
|
||||
let depreciation = Open::TaxDepreciation {
|
||||
book: d("1370.00"),
|
||||
contributed_book: d("1370.00"),
|
||||
};
|
||||
assert_eq!(tc.open, vec![depreciation.clone()]);
|
||||
for row in &tc.rows {
|
||||
// The book column is the K-1 worksheet's row.
|
||||
assert_eq!(row.book.beginning, d("0"));
|
||||
assert_eq!(row.book.contributions_cash, d("1000.00"));
|
||||
assert_eq!(row.book.contributions_property, d("1370.00"));
|
||||
assert_eq!(row.book.net_income, d("-385.00"));
|
||||
assert_eq!(row.book.distributions, d("300.00"));
|
||||
assert_eq!(row.book.ending, d("1685.00"));
|
||||
// The tax column.
|
||||
assert_eq!(row.beginning.tax(), Some(d("0")));
|
||||
assert_eq!(row.contributions_cash, d("1000.00"));
|
||||
assert_eq!(row.contributions_property.tax(), Some(d("1384.20")));
|
||||
assert_eq!(row.net_income.tax(), None, "tax depreciation is the CPA's");
|
||||
assert_eq!(row.net_income.known, d("-385.00"));
|
||||
assert_eq!(row.net_income.open, vec![depreciation.clone()]);
|
||||
assert_eq!(row.other.tax(), Some(d("0")));
|
||||
assert_eq!(row.distributions.tax(), Some(d("300.00")));
|
||||
assert_eq!(row.ending.tax(), None);
|
||||
assert_eq!(row.ending.known, d("1699.20"));
|
||||
assert_eq!(row.ending.open, vec![depreciation.clone()]);
|
||||
// Book less tax, by cause.
|
||||
assert_eq!(
|
||||
row.reconciliation,
|
||||
Reconciliation {
|
||||
beginning: d("0"),
|
||||
built_in_gain_704c: d("-14.20"),
|
||||
property_without_basis: d("0"),
|
||||
property_distributed: d("0"),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beginning capital the next year is this year's ending as the books
|
||||
/// know it, left open while an earlier year left a figure open.
|
||||
#[test]
|
||||
fn next_years_beginning_carries_what_is_known_and_says_what_isnt() {
|
||||
let (b, _, _, _) = the_year();
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2027, 1, 1),
|
||||
date(2027, 12, 31),
|
||||
&members(&b),
|
||||
&[b.cash],
|
||||
)
|
||||
.unwrap();
|
||||
for row in &tc.rows {
|
||||
assert_eq!(row.book.beginning, d("1685.00"));
|
||||
assert_eq!(row.beginning.known, d("1699.20"));
|
||||
assert_eq!(row.beginning.open, vec![Open::PriorYears]);
|
||||
assert_eq!(row.reconciliation.beginning, d("-14.20"));
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cash only, no fixed assets: every line is the tax figure, equal to the
|
||||
/// book figure.
|
||||
#[test]
|
||||
fn cash_only_books_are_complete() {
|
||||
let mut b = books();
|
||||
let (cash, tobin, morgan, revenue) = (b.cash, b.tobin, b.morgan, b.revenue);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 3, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "500.00"),
|
||||
(tobin, Side::Credit, "250.00"),
|
||||
(morgan, Side::Credit, "250.00"),
|
||||
],
|
||||
);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 4, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "100.01"),
|
||||
(revenue, Side::Credit, "100.01"),
|
||||
],
|
||||
);
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[cash],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tc.open.is_empty());
|
||||
for row in &tc.rows {
|
||||
assert_eq!(row.ending.tax(), Some(row.book.ending));
|
||||
assert!(row.ending.open.is_empty());
|
||||
assert_eq!(row.reconciliation, Reconciliation::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Property contributed with no basis in the books (a prepaid contract
|
||||
/// handed over, no registered asset) is open with its book amount; a
|
||||
/// linked asset carrying a purchase's basis on a contribution is open
|
||||
/// too. Neither is guessed.
|
||||
#[test]
|
||||
fn property_without_a_basis_is_open_never_guessed() {
|
||||
let mut b = books();
|
||||
let (prepaid, equipment, tobin, morgan, cash) =
|
||||
(b.prepaid, b.equipment, b.tobin, b.morgan, b.cash);
|
||||
let contract = post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 5),
|
||||
&[
|
||||
(prepaid, Side::Debit, "25.00"),
|
||||
(tobin, Side::Credit, "12.50"),
|
||||
(morgan, Side::Credit, "12.50"),
|
||||
],
|
||||
);
|
||||
let tool = post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 6),
|
||||
&[
|
||||
(equipment, Side::Debit, "400.00"),
|
||||
(tobin, Side::Credit, "400.00"),
|
||||
],
|
||||
);
|
||||
let id =
|
||||
b.l.register_asset(NewAsset {
|
||||
description: "Drill".into(),
|
||||
in_service_date: date(2026, 1, 6),
|
||||
cost: usd("400.00"),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 24,
|
||||
cost_account: b.equipment,
|
||||
accum_depr_account: b.accum,
|
||||
expense_account: b.depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
b.l.set_asset_acquisition(id, tool).unwrap();
|
||||
b.l.set_asset_basis(id, AssetBasis::purchase()).unwrap();
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[cash],
|
||||
)
|
||||
.unwrap();
|
||||
let (t, m) = (&tc.rows[0], &tc.rows[1]);
|
||||
assert_eq!(t.book.contributions_property, d("412.50"));
|
||||
assert_eq!(t.contributions_property.known, d("0"));
|
||||
assert_eq!(
|
||||
t.contributions_property.open,
|
||||
vec![
|
||||
Open::PropertyBasis {
|
||||
entry: contract,
|
||||
book: d("12.50")
|
||||
},
|
||||
Open::PropertyBasis {
|
||||
entry: tool,
|
||||
book: d("400.00")
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(t.reconciliation.property_without_basis, d("412.50"));
|
||||
assert_eq!(m.contributions_property.known, d("0"));
|
||||
assert_eq!(m.reconciliation.property_without_basis, d("12.50"));
|
||||
for row in &tc.rows {
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Property distributed: its adjusted tax basis is the CPA's (it needs
|
||||
/// tax depreciation), so the line is open with its book amount.
|
||||
#[test]
|
||||
fn property_distributed_is_open_at_its_book_amount() {
|
||||
let mut b = books();
|
||||
let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 1),
|
||||
&[
|
||||
(cash, Side::Debit, "1000.00"),
|
||||
(tobin, Side::Credit, "500.00"),
|
||||
(morgan, Side::Credit, "500.00"),
|
||||
],
|
||||
);
|
||||
post(
|
||||
&mut b.l,
|
||||
date(2026, 1, 2),
|
||||
&[
|
||||
(equipment, Side::Debit, "100.00"),
|
||||
(cash, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
let out = post(
|
||||
&mut b.l,
|
||||
date(2026, 6, 1),
|
||||
&[
|
||||
(tobin, Side::Debit, "100.00"),
|
||||
(equipment, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
let tc = tax_basis_capital(
|
||||
&b.l,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 12, 31),
|
||||
&members(&b),
|
||||
&[cash],
|
||||
)
|
||||
.unwrap();
|
||||
let t = &tc.rows[0];
|
||||
assert_eq!(t.book.distributions, d("100.00"));
|
||||
assert_eq!(t.distributions.known, d("0"));
|
||||
assert_eq!(
|
||||
t.distributions.open,
|
||||
vec![Open::PropertyDistribution {
|
||||
entry: out,
|
||||
book: d("100.00")
|
||||
}]
|
||||
);
|
||||
assert_eq!(t.reconciliation.property_distributed, d("100.00"));
|
||||
assert_eq!(
|
||||
t.reconciliation.total(),
|
||||
Some(t.book.ending - t.ending.known)
|
||||
);
|
||||
assert_eq!(tc.rows[1].distributions.tax(), Some(d("0")));
|
||||
}
|
||||
|
||||
/// tax_capital INVARIANTS 4 and 5 hold for every row of the year and of
|
||||
/// every span within it.
|
||||
#[test]
|
||||
fn every_span_rolls_forward_and_reconciles() {
|
||||
let (b, _, _, _) = the_year();
|
||||
for (start, end) in [
|
||||
(date(2026, 1, 1), date(2026, 6, 30)),
|
||||
(date(2026, 7, 1), date(2026, 12, 31)),
|
||||
(date(2026, 12, 15), date(2026, 12, 15)),
|
||||
] {
|
||||
let tc = tax_basis_capital(&b.l, start, end, &members(&b), &[b.cash]).unwrap();
|
||||
for row in &tc.rows {
|
||||
assert_eq!(
|
||||
row.ending.known,
|
||||
row.beginning.known
|
||||
+ row.contributions_cash
|
||||
+ row.contributions_property.known
|
||||
+ row.net_income.known
|
||||
+ row.other.known
|
||||
- row.distributions.known,
|
||||
"{start}..{end}"
|
||||
);
|
||||
assert_eq!(
|
||||
row.reconciliation.total(),
|
||||
Some(row.book.ending - row.ending.known),
|
||||
"{start}..{end}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user