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

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

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

No files matched your search

+411 -128
View File
@@ -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
+39 -8
View File
@@ -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
+13 -9
View File
@@ -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
+113 -1
View File
@@ -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));
+431 -50
View File
@@ -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) {
+6 -4
View File
@@ -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,
+44
View File
@@ -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
// ---------------------------------------------------------------------------
+184 -92
View File
@@ -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>(),
})
+565
View File
@@ -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")]
);
}
}