From 6288ead747e3c4588a6781a3e2fa4c55ccf1bbca Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 21 Oct 2026 15:10:13 -0600 Subject: [PATCH] P3 review: asset bases and the tax-basis K-1 Schema 13 is as shipped and the asset columns are schema 14 (shipped migrations pinned by hash); the tax-basis K-1 leaves net income open (only the return has it) and takes a contributed prepaid plan at its book amount as its section 723 basis; a disposal of contributed property names its 704(c) gain or loss; aliases compare by meaning; fixture amounts made up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- crates/ledger-core/src/k1.rs | 14 +- crates/ledger-core/src/ledger.rs | 23 +- crates/ledger-core/src/tax_capital.rs | 107 +++++++- crates/ledger-core/tests/assets.rs | 6 +- crates/ledger-core/tests/spec_asset_basis.rs | 16 ++ crates/ledger-core/tests/tax_capital.rs | 274 +++++++++++++++++-- crates/ledger-gui/src/pages/assets.rs | 9 +- crates/ledger-gui/src/pages/company.rs | 7 + crates/ledger-gui/src/pages/journal.rs | 5 +- crates/ledger-gui/src/pages/reports.rs | 35 ++- crates/ledger-mcp/handbook/cpa-handoff.md | 2 +- crates/ledger-mcp/handbook/fixed-assets.md | 2 +- crates/ledger-mcp/src/server.rs | 60 +++- crates/ledger-mcp/tests/spec_assets.rs | 87 ++++++ crates/ledger-package/src/k1_tax.rs | 105 ++++++- crates/ledger-package/src/package.rs | 2 +- crates/ledger-package/tests/package.rs | 5 +- crates/ledger-store/src/schema.rs | 30 +- crates/ledger-store/tests/asset_tags.rs | 81 +++++- crates/ledger-store/tests/schema_files.rs | 33 +++ docs/mcp-interface.md | 24 +- docs/spec/rules.md | 41 +-- docs/spec/sources/irs-partnerships.md | 2 +- docs/testing.md | 17 +- 24 files changed, 860 insertions(+), 127 deletions(-) diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index 7030763..23d0243 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -724,8 +724,8 @@ mod tests { } /// INVARIANT 3, by hand: a contract the members hand over (Dr 1300 - /// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the - /// MacBook) is property, 12.50 each. Members paying a 3,000 annual + /// Prepaid 36 / Cr 3000 18.00, Cr 3001 18.00, a service plan with a + /// laptop) is property, 18.00 each. Members paying a 3,000 annual /// service for the company go through 2100: Dr 1300 / Cr 2100 3,000, /// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each. #[test] @@ -737,13 +737,13 @@ mod tests { 1, 5, &[ - (prepaid, Side::Debit, "25"), - (a, Side::Credit, "12.50"), - (b, Side::Credit, "12.50"), + (prepaid, Side::Debit, "36"), + (a, Side::Credit, "18.00"), + (b, Side::Credit, "18.00"), ], ); let ms = members(&bk); - let handed = (d("0"), d("12.50"), d("0")); + let handed = (d("0"), d("18.00"), d("0")); assert_eq!(split(&bk, &ms), vec![handed, handed]); let due = bk.l.add_account( @@ -770,7 +770,7 @@ mod tests { (b, Side::Credit, "1500"), ], ); - let each = (d("1500"), d("12.50"), d("0")); + let each = (d("1500"), d("18.00"), d("0")); assert_eq!(split(&bk, &ms), vec![each, each]); } diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 2ba1254..029f7e8 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -2586,7 +2586,11 @@ impl Ledger { /// 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`]). + /// store checks new runs first, [`Self::check_depreciation_run`]). A + /// second line for the same asset, period and entry (a journal edited + /// by hand; a store never writes one) is the run already recorded: one + /// entry is carried once, as the projection keys it (P3 review, + /// 2026-10-21: replay counted it twice, the reload once). pub fn record_depreciation_run( &mut self, asset: AssetId, @@ -2600,14 +2604,15 @@ impl Ledger { 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, - }); + let runs = self.asset_runs.entry(asset).or_default(); + if runs.iter().any(|r| r.entry == entry && r.period == period) { + return Ok(()); + } + runs.push(assets::DepreciationPosting { + period: period.to_string(), + entry, + amount, + }); Ok(()) } diff --git a/crates/ledger-core/src/tax_capital.rs b/crates/ledger-core/src/tax_capital.rs index 1c0235a..35209b1 100644 --- a/crates/ledger-core/src/tax_capital.rs +++ b/crates/ledger-core/src/tax_capital.rs @@ -31,20 +31,29 @@ //! `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. +//! - Net income: always the return's (Item L: "as figured for tax +//! purposes"); the books are kept on their own basis and can't +//! know a book-tax difference (Schedule M-1). `known` is the book +//! share; the open items name the differences the books do see: +//! tax depreciation of every fixed asset in service in the span +//! (MACRS, and for contributed property the §704(c) method's +//! allocation), and the tax gain on a disposal, with the +//! property's built-in gain or loss at contribution (§704(c)(1)(A)). //! - 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. +//! the row, and so is always open (net income is). //! 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. +//! 6. Property contributed to a prepaid account (a prepaid-expense +//! schedule's balance account) with no fixed asset for it is taken +//! at its book amount, with a note (Note::PrepaidAtBook), never open: +//! a service contract's §723 basis is what the books recorded (the +//! CPA to confirm). //! SCENARIOS: //! 1. IF two members jointly contribute a laptop worth 1,440.00 whose //! adjusted basis is 1,688.40, and nothing else happens: @@ -55,6 +64,9 @@ //! - 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). +//! 3. IF nothing but cash comes in and goes out: +//! - SHOULD still leave the tax net income open, the book share known +//! BECAUSE income "as figured for tax purposes" is the return's. //! ``` use crate::k1::{K1Kind, K1Row, k1_detail}; @@ -158,12 +170,26 @@ pub enum Open { contributed_book: Decimal, }, /// A fixed asset disposed in the span: its tax gain or loss (from its - /// adjusted tax basis) is the CPA's. + /// adjusted tax basis) is the CPA's. For contributed property, the gain + /// or loss is shared so as to take account of its built-in gain or loss + /// (IRC §704(c)(1)(A)), which goes to the contributing member(s). Disposal { /// The asset. asset: AssetId, /// The proceeds. proceeds: Decimal, + /// Its §704(c) built-in gain (+) or loss (−) at contribution, when + /// it is contributed property with a basis recorded; `None` for a + /// purchase or no basis. + built_in_gain_704c: Option, + }, + /// The member's share of net income (loss) as figured for tax purposes + /// (Item L): the return's, never the books'. The books are kept on + /// their own basis; every difference between book and tax income + /// (Schedule M-1) is the CPA's. `book` is the member's book share. + TaxNetIncome { + /// The member's share of book net income (loss). + book: 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. @@ -173,6 +199,24 @@ pub enum Open { }, } +/// A figure the worksheet takes on a stated rule, for the CPA to confirm +/// (INVARIANT 6). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Note { + /// Property contributed in `entry` to a prepaid account (the balance + /// account of a prepaid-expense schedule: a service contract the + /// member handed over, B-13) with no fixed asset for it: its §723 + /// basis is taken as its book amount, this member's share `book` + /// (DECISION, P3 review 2026-10-21: the contract's carryover basis is + /// what the books recorded for it; the CPA to confirm). + PrepaidAtBook { + /// The contribution entry. + entry: EntryId, + /// This member's share of the prepaid, at book and at basis. + book: Decimal, + }, +} + /// One Item L line on the tax basis ([`Open`]: INVARIANT 3). #[derive(Clone, Debug, PartialEq, Eq)] pub struct TaxLine { @@ -181,6 +225,9 @@ pub struct TaxLine { /// What the CPA must still settle; empty when `known` is the tax /// figure. pub open: Vec, + /// Figures taken on a stated rule, for the CPA to confirm (INVARIANT + /// 6); they never make a line open. + pub notes: Vec, } impl TaxLine { @@ -188,6 +235,7 @@ impl TaxLine { Self { known, open: Vec::new(), + notes: Vec::new(), } } @@ -319,6 +367,8 @@ fn share(whole: Decimal, weights: &[Decimal]) -> Result, LedgerErro struct EntryProperty { /// Σ built-in gain of the linked assets with a contribution basis. built_in_gain: Decimal, + /// The property debited to prepaid accounts (INVARIANT 6), at book. + prepaid: 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, @@ -339,6 +389,24 @@ fn entry_property( }) .try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount)) .ok_or_else(too_large)?; + // Prepaid accounts: those a prepaid-expense schedule amortizes, and no + // registered asset carries its cost in (INVARIANT 6). + let prepaid_account = |id: AccountId| { + ledger.schedules().any(|s| { + s.kind == crate::schedules::ScheduleKind::PrepaidExpense && s.balance_account == id + }) && !ledger.registered_assets().any(|a| a.cost_account == id) + }; + let prepaid = entry + .lines() + .iter() + .filter(|l| { + l.side == Side::Debit + && crate::k1::is_property_account(ledger, cash, l.account) + && prepaid_account(l.account) + }) + .try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount)) + .ok_or_else(too_large)?; + let pool = pool - prepaid; let (mut linked, mut big, mut unknown) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); for a in ledger .registered_assets() @@ -359,6 +427,7 @@ fn entry_property( if linked > pool { return Ok(EntryProperty { built_in_gain: Decimal::ZERO, + prepaid, without_basis: pool, mismatch: Some((linked, pool)), }); @@ -366,6 +435,7 @@ fn entry_property( let rest = pool - linked; Ok(EntryProperty { built_in_gain: big, + prepaid, without_basis: unknown.checked_add(rest).ok_or_else(too_large)?, mismatch: None, }) @@ -385,6 +455,7 @@ fn span( let mut without = vec![Decimal::ZERO; n]; let mut distributed = vec![Decimal::ZERO; n]; let mut prop_open: Vec> = vec![Vec::new(); n]; + let mut prop_notes: Vec> = vec![Vec::new(); n]; let mut dist_open: Vec> = vec![Vec::new(); n]; // Entries in journal order, each once. let mut entries: Vec = Vec::new(); @@ -412,7 +483,14 @@ fn span( let p = entry_property(ledger, e, cash)?; let g = share(p.built_in_gain, &takes)?; let w = share(p.without_basis, &takes)?; + let pp = share(p.prepaid, &takes)?; for i in (0..n).filter(|&i| !takes[i].is_zero()) { + if !pp[i].is_zero() { + prop_notes[i].push(Note::PrepaidAtBook { + entry: id, + book: pp[i], + }); + } 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 { @@ -495,6 +573,7 @@ fn span( open.push(Open::Disposal { asset: a.id, proceeds: proceeds.amount, + built_in_gain_704c: a.basis_figures()?.and_then(|f| f.built_in_gain_704c), }); } } @@ -508,6 +587,7 @@ fn span( } else { vec![Open::PriorYears] }, + notes: Vec::new(), }, None => TaxLine::complete(book.beginning), }; @@ -518,10 +598,16 @@ fn span( .and_then(|x| x.checked_sub(without[i])) .ok_or_else(too_large)?, open: std::mem::take(&mut prop_open[i]), + notes: std::mem::take(&mut prop_notes[i]), }; let net_income = TaxLine { known: book.net_income, - open: open.clone(), + open: std::iter::once(Open::TaxNetIncome { + book: book.net_income, + }) + .chain(open.iter().cloned()) + .collect(), + notes: Vec::new(), }; let other = TaxLine { known: book.other, @@ -530,6 +616,7 @@ fn span( } else { vec![Open::OtherChange { book: book.other }] }, + notes: Vec::new(), }; let distributions = TaxLine { known: book @@ -537,6 +624,7 @@ fn span( .checked_sub(distributed[i]) .ok_or_else(too_large)?, open: std::mem::take(&mut dist_open[i]), + notes: Vec::new(), }; let known = beginning .known @@ -558,6 +646,7 @@ fn span( .iter() .flat_map(|l| l.open.iter().cloned()) .collect(), + notes: Vec::new(), }; let reconciliation = Reconciliation { beginning: book @@ -601,8 +690,8 @@ mod tests { vec![d("-510.69"), d("-510.68")] ); assert_eq!( - share(d("25"), &[d("525"), d("525")]).unwrap(), - vec![d("12.50"), d("12.50")] + share(d("36"), &[d("525"), d("525")]).unwrap(), + vec![d("18.00"), d("18.00")] ); assert_eq!( share(d("10"), &[d("0"), d("0")]).unwrap(), diff --git a/crates/ledger-core/tests/assets.rs b/crates/ledger-core/tests/assets.rs index ead0454..6489225 100644 --- a/crates/ledger-core/tests/assets.rs +++ b/crates/ledger-core/tests/assets.rs @@ -463,11 +463,11 @@ fn acquisition_links_only_to_a_live_entry_debiting_the_cost_account() { let capital = ledger .add_account("3000", "Capital", AccountType::Equity, None, false) .unwrap(); - let id = van_asset(&mut ledger, 1650, cost, accum, expense); + let id = van_asset(&mut ledger, 1480, cost, accum, expense); let post = |l: &mut Ledger, dr: u64, cr: u64| { let e = l.create_entry(date(2026, 1, 15), "contribution"); - l.add_line(e, dr, Side::Debit, usd("1650")).unwrap(); - l.add_line(e, cr, Side::Credit, usd("1650")).unwrap(); + l.add_line(e, dr, Side::Debit, usd("1480")).unwrap(); + l.add_line(e, cr, Side::Credit, usd("1480")).unwrap(); l.post_entry(e).unwrap(); e }; diff --git a/crates/ledger-core/tests/spec_asset_basis.rs b/crates/ledger-core/tests/spec_asset_basis.rs index ef1310f..934aa39 100644 --- a/crates/ledger-core/tests/spec_asset_basis.rs +++ b/crates/ledger-core/tests/spec_asset_basis.rs @@ -1021,6 +1021,22 @@ fn the_registers_book_value_is_what_the_books_carry() { let _ = id; } +/// A second line for the same run (asset, period and entry: a journal +/// edited by hand) is the run already recorded, as the projection keys it: +/// its entry is carried once (P3 review, 2026-10-21: replay counted it +/// twice, 200.00, and a reload once). +#[test] +fn a_duplicated_run_line_is_carried_once() { + let (mut l, _, id, e) = one_run(); + l.record_depreciation_run(id, "2026-01", e, d("100.00")) + .unwrap(); + assert_eq!(l.depreciation_runs(id).len(), 1); + assert_eq!( + l.accumulated_posted(id, date(2026, 1, 31)).unwrap(), + d("100.00") + ); +} + /// check_depreciation_run: a month, a positive amount in cents, a posted /// entry crediting the asset's accumulated depreciation by exactly that; /// record_depreciation_run (replay) needs only the asset and entry. diff --git a/crates/ledger-core/tests/tax_capital.rs b/crates/ledger-core/tests/tax_capital.rs index b313a2c..7d19fc5 100644 --- a/crates/ledger-core/tests/tax_capital.rs +++ b/crates/ledger-core/tests/tax_capital.rs @@ -14,10 +14,10 @@ use std::str::FromStr; use chrono::NaiveDate; use ledger_core::reports::MemberCapital; -use ledger_core::tax_capital::{Open, Reconciliation, tax_basis_capital}; +use ledger_core::tax_capital::{Note, Open, Reconciliation, tax_basis_capital}; use ledger_core::{ AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money, - NewAsset, Sec704cMethod, Side, + NewAsset, NewSchedule, ScheduleConvention, ScheduleKind, Sec704cMethod, Side, }; use rust_decimal::Decimal; @@ -243,12 +243,16 @@ fn tax_basis_capital_per_member() { assert_eq!(row.contributions_property.tax(), Some(d("1384.20"))); assert_eq!(row.net_income.tax(), None, "tax depreciation is the CPA's"); assert_eq!(row.net_income.known, d("-385.00")); - assert_eq!(row.net_income.open, vec![depreciation.clone()]); + let income = Open::TaxNetIncome { book: d("-385.00") }; + assert_eq!( + row.net_income.open, + vec![income.clone(), depreciation.clone()] + ); assert_eq!(row.other.tax(), Some(d("0"))); assert_eq!(row.distributions.tax(), Some(d("300.00"))); assert_eq!(row.ending.tax(), None); assert_eq!(row.ending.known, d("1699.20")); - assert_eq!(row.ending.open, vec![depreciation.clone()]); + assert_eq!(row.ending.open, vec![income, depreciation.clone()]); // Book less tax, by cause. assert_eq!( row.reconciliation, @@ -292,9 +296,10 @@ fn next_years_beginning_carries_what_is_known_and_says_what_isnt() { } /// Cash only, no fixed assets: every line is the tax figure, equal to the -/// book figure. +/// book figure, but net income (and so the ending), which is the return's +/// whatever the books hold (Item L: "as figured for tax purposes"). #[test] -fn cash_only_books_are_complete() { +fn cash_only_books_leave_only_the_tax_income_open() { let mut b = books(); let (cash, tobin, morgan, revenue) = (b.cash, b.tobin, b.morgan, b.revenue); post( @@ -323,9 +328,17 @@ fn cash_only_books_are_complete() { ) .unwrap(); assert!(tc.open.is_empty()); - for row in &tc.rows { - assert_eq!(row.ending.tax(), Some(row.book.ending)); - assert!(row.ending.open.is_empty()); + for (row, income) in tc.rows.iter().zip(["50.01", "50.00"]) { + let income = vec![Open::TaxNetIncome { book: d(income) }]; + assert_eq!(row.beginning.tax(), Some(d("0"))); + assert_eq!(row.contributions_cash, d("250.00")); + assert_eq!(row.contributions_property.tax(), Some(d("0"))); + assert_eq!(row.distributions.tax(), Some(d("0"))); + assert_eq!(row.net_income.tax(), None, "the return's, never the books'"); + assert_eq!(row.net_income.open, income); + assert_eq!(row.ending.tax(), None); + assert_eq!(row.ending.known, row.book.ending); + assert_eq!(row.ending.open, income); assert_eq!(row.reconciliation, Reconciliation::default()); } } @@ -343,9 +356,9 @@ fn property_without_a_basis_is_open_never_guessed() { &mut b.l, date(2026, 1, 5), &[ - (prepaid, Side::Debit, "25.00"), - (tobin, Side::Credit, "12.50"), - (morgan, Side::Credit, "12.50"), + (prepaid, Side::Debit, "36.00"), + (tobin, Side::Credit, "18.00"), + (morgan, Side::Credit, "18.00"), ], ); let tool = post( @@ -379,14 +392,14 @@ fn property_without_a_basis_is_open_never_guessed() { ) .unwrap(); let (t, m) = (&tc.rows[0], &tc.rows[1]); - assert_eq!(t.book.contributions_property, d("412.50")); + assert_eq!(t.book.contributions_property, d("418.00")); assert_eq!(t.contributions_property.known, d("0")); assert_eq!( t.contributions_property.open, vec![ Open::PropertyBasis { entry: contract, - book: d("12.50") + book: d("18.00") }, Open::PropertyBasis { entry: tool, @@ -394,9 +407,9 @@ fn property_without_a_basis_is_open_never_guessed() { }, ] ); - assert_eq!(t.reconciliation.property_without_basis, d("412.50")); + assert_eq!(t.reconciliation.property_without_basis, d("418.00")); assert_eq!(m.contributions_property.known, d("0")); - assert_eq!(m.reconciliation.property_without_basis, d("12.50")); + assert_eq!(m.reconciliation.property_without_basis, d("18.00")); for row in &tc.rows { assert_eq!( row.reconciliation.total(), @@ -492,3 +505,232 @@ fn every_span_rolls_forward_and_reconciles() { } } } + +/// A contributed asset with `rule`, in service 2026-01-01 over 24 months. +fn contributed( + b: &mut Books, + rule: BasisRule, + fmv: &str, + adjusted: &str, + entry: EntryId, +) -> AssetId { + let id = + b.l.register_asset(NewAsset { + description: format!("Device {fmv}"), + in_service_date: date(2026, 1, 1), + cost: usd(fmv), + salvage_value: usd("0.00"), + useful_life_months: 24, + cost_account: b.equipment, + accum_depr_account: b.accum, + expense_account: b.depreciation, + }) + .unwrap(); + b.l.set_asset_acquisition(id, entry).unwrap(); + b.l.set_asset_basis(id, AssetBasis::contribution(rule, d(adjusted), d(fmv))) + .unwrap(); + id +} + +/// Four devices and a prepaid service plan two members hand over jointly +/// from personal use, in one entry (P3 review, by hand; invented figures): +/// - D1 worth 1,200.00, basis 1,500.00; D2 worth 800.00, basis 1,100.00 +/// (both worth less than basis); D3 worth 1,000.00, basis 700.00 (worth +/// more); D4 worth 600.00, basis 600.00 (equal); the plan 40.00, a +/// prepaid-expense schedule's account. Dr 1500 3,600.00, Dr 1300 40.00 +/// / Cr 3000 1,820.00, Cr 3001 1,820.00. +/// - Depreciable basis, the lower of value and basis: 1,200.00 + 800.00 + +/// 700.00 + 600.00 = 3,300.00. §723: 1,500.00 + 1,100.00 + 700.00 + +/// 600.00 = 3,900.00. Built-in: −300.00, −300.00, +300.00, 0.00 = +/// −300.00, never netted per property (D1 and D2 §704(c)(1)(C)). +/// - Item L property, each: book 1,820.00; tax (3,900.00 + 40.00) / 2 = +/// 1,970.00 = 1,820.00 − (−300.00 / 2), the plan at its book 20.00 each +/// with a note, never open. +/// - D3 sold 2026-06-30 for 1,100.00: its tax gain is the CPA's, flagged +/// with its built-in gain 300.00 (§704(c)(1)(A)). +#[test] +fn four_devices_and_a_plan_by_hand() { + let mut b = books(); + let (equipment, prepaid, tobin, morgan, cash) = + (b.equipment, b.prepaid, b.tobin, b.morgan, b.cash); + let plan_expense = + b.l.add_account("6300", "Service plans", AccountType::Expense, None, false) + .unwrap(); + b.l.register_schedule(NewSchedule { + description: "Service plan".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd("40.00"), + start: date(2026, 1, 1), + months: 12, + balance_account: prepaid, + pl_account: plan_expense, + convention: ScheduleConvention::Daily, + }) + .unwrap(); + let e = post( + &mut b.l, + date(2026, 1, 1), + &[ + (equipment, Side::Debit, "1200.00"), + (equipment, Side::Debit, "800.00"), + (equipment, Side::Debit, "1000.00"), + (equipment, Side::Debit, "600.00"), + (prepaid, Side::Debit, "40.00"), + (tobin, Side::Credit, "1820.00"), + (morgan, Side::Credit, "1820.00"), + ], + ); + let p = BasisRule::PersonalUseConversion; + let ids = [ + contributed(&mut b, p, "1200.00", "1500.00", e), + contributed(&mut b, p, "800.00", "1100.00", e), + contributed(&mut b, p, "1000.00", "700.00", e), + contributed(&mut b, p, "600.00", "600.00", e), + ]; + let a = |i: usize| b.l.get_asset(ids[i]).unwrap(); + let want = [ + ("1200.00", "1500.00", "-300.00", true), + ("800.00", "1100.00", "-300.00", true), + ("700.00", "700.00", "300.00", false), + ("600.00", "600.00", "0.00", false), + ]; + for (i, (dep, t723, big, loss)) in want.into_iter().enumerate() { + assert_eq!(a(i).depreciable_basis(), Some(d(dep)), "D{}", i + 1); + assert_eq!(a(i).tax_basis_723(), Some(d(t723)), "D{}", i + 1); + assert_eq!(a(i).built_in_gain_704c(), Some(d(big)), "D{}", i + 1); + assert_eq!(a(i).sec704c_1c(), loss, "D{}", i + 1); + } + b.l.dispose_asset(ids[2], date(2026, 6, 30), usd("1100.00")) + .unwrap(); + let tc = tax_basis_capital( + &b.l, + date(2026, 1, 1), + date(2026, 12, 31), + &members(&b), + &[cash], + ) + .unwrap(); + assert!(tc.open.contains(&Open::Disposal { + asset: ids[2], + proceeds: d("1100.00"), + built_in_gain_704c: Some(d("300.00")), + })); + for row in &tc.rows { + assert_eq!(row.book.contributions_property, d("1820.00")); + assert_eq!(row.contributions_property.tax(), Some(d("1970.00"))); + assert_eq!( + row.contributions_property.notes, + vec![Note::PrepaidAtBook { + entry: e, + book: d("20.00") + }] + ); + assert_eq!(row.reconciliation.built_in_gain_704c, d("-150.00")); + assert_eq!(row.reconciliation.property_without_basis, d("0")); + assert_eq!( + row.reconciliation.total(), + Some(row.book.ending - row.ending.known) + ); + } +} + +/// Property contributed subject to a liability (Item L: "reduced by any +/// liabilities ... to which the property is subject"), by hand: worth +/// 500.00, basis 200.00, a 300.00 loan on it; Dr 1500 500.00 / Cr 2200 +/// 300.00, Cr 3000 200.00. Book property 200.00 (value net of the loan); +/// tax 200.00 − 300.00 = −100.00 ("This amount might be negative"); the +/// other member nothing. +#[test] +fn property_subject_to_a_liability_enters_net_of_it() { + let mut b = books(); + let (equipment, tobin, cash) = (b.equipment, b.tobin, b.cash); + let loan = + b.l.add_account("2200", "Loan", AccountType::Liability, None, false) + .unwrap(); + let e = post( + &mut b.l, + date(2026, 1, 1), + &[ + (equipment, Side::Debit, "500.00"), + (loan, Side::Credit, "300.00"), + (tobin, Side::Credit, "200.00"), + ], + ); + contributed(&mut b, BasisRule::Carryover723, "500.00", "200.00", e); + let tc = tax_basis_capital( + &b.l, + date(2026, 1, 1), + date(2026, 12, 31), + &members(&b), + &[cash], + ) + .unwrap(); + let (t, m) = (&tc.rows[0], &tc.rows[1]); + assert_eq!(t.book.contributions_property, d("200.00")); + assert_eq!(t.contributions_property.tax(), Some(d("-100.00"))); + assert_eq!(t.reconciliation.built_in_gain_704c, d("300.00")); + assert_eq!(m.contributions_property.tax(), Some(d("0"))); + assert_eq!(m.reconciliation, Reconciliation::default()); +} + +/// Three members with unequal shares, one contributing nothing, by hand: +/// a 1,000.00 machine (basis 400.00, business use) handed over 600.00 by +/// the first and 400.00 by the second; its 600.00 built-in gain is shared +/// as its value is, 360.00 and 240.00, so tax property 240.00 and 160.00 +/// (the basis, 0.6 and 0.4); the third member has every line 0.00. +#[test] +fn unequal_contributors_and_a_member_with_none() { + let mut b = books(); + let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); + let third = + b.l.add_account( + "3002", + "Capital, Avery Quinn", + AccountType::Equity, + None, + false, + ) + .unwrap(); + let e = post( + &mut b.l, + date(2026, 1, 1), + &[ + (equipment, Side::Debit, "1000.00"), + (tobin, Side::Credit, "600.00"), + (morgan, Side::Credit, "400.00"), + ], + ); + contributed(&mut b, BasisRule::Carryover723, "1000.00", "400.00", e); + let [t, m] = members(&b); + let ms = [ + MemberCapital { + share: d("0.5"), + ..t + }, + MemberCapital { + share: d("0.3"), + ..m + }, + MemberCapital { + name: "Avery Quinn".into(), + capital_account: third, + distribution_account: None, + share: d("0.2"), + }, + ]; + let tc = tax_basis_capital(&b.l, date(2026, 1, 1), date(2026, 12, 31), &ms, &[cash]).unwrap(); + let want = [ + ("600.00", "240.00", "360.00"), + ("400.00", "160.00", "240.00"), + ]; + for (row, (book, tax, big)) in tc.rows.iter().zip(want) { + assert_eq!(row.book.contributions_property, d(book)); + assert_eq!(row.contributions_property.tax(), Some(d(tax))); + assert_eq!(row.reconciliation.built_in_gain_704c, d(big)); + } + let none = &tc.rows[2]; + assert_eq!(none.book.ending, d("0")); + assert_eq!(none.contributions_property.tax(), Some(d("0"))); + assert_eq!(none.ending.known, d("0")); + assert_eq!(none.reconciliation, Reconciliation::default()); +} diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index ca9a6d6..ff79e06 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -203,8 +203,11 @@ pub fn book_vs_tax(rule: Option) -> &'static str { Some(BasisRule::Purchase) => { "Book cost and tax basis are both what the company paid; tax depreciation starts from the tax basis — the CPA's." } - _ => { - "Book value is the fair market value at contribution; the tax figures are at contribution, and tax depreciation starts from the depreciable basis — the CPA's." + Some(BasisRule::Carryover723 | BasisRule::PersonalUseConversion) => { + "Book cost is the fair market value at contribution, and book value that cost less the depreciation posted; the tax figures are at contribution, and tax depreciation starts from the depreciable basis — the CPA's." + } + None => { + "No tax basis is recorded: book value is cost less the depreciation posted; the tax figures follow once the basis is recorded." } } } @@ -1433,7 +1436,7 @@ fn tax_card(ui: &mut Ui, a: &Asset) { ui.add( egui::Label::new( RichText::new( - "Ask the bookkeeper to record it (books.set_asset_tax_basis): was it bought by the company, contributed by a member from business use, or converted from personal use? Book value is the fair market value at contribution; tax depreciation starts from the tax basis — the CPA's.", + "Ask the bookkeeper to record it (books.set_asset_tax_basis): was it bought by the company, contributed by a member from business use, or converted from personal use? For a contribution, book cost is the fair market value at contribution; tax depreciation starts from the depreciable basis — the CPA's.", ) .font(theme::regular(12.5)) .color(p.muted), diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 150b197..0b1abbe 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -363,6 +363,9 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { AssetStatus::Disposed { date, .. } if *date <= b.today => Some(*date), _ => None, }; + // Not on the books until its acquired date: no book value + // yet (the register leaves it out the same way). + let later = b.ledger.asset_acquired_date(a.id).filter(|d| *d > b.today); let acc = b.ledger.accumulated_posted(a.id, b.today); let scheduled = accumulated_scheduled_through(a, b.today); let dep = a.cost.amount - a.salvage_value.amount; @@ -391,6 +394,10 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let text = match (&acc, disposed) { + _ if later.is_some() => format!( + "not on the books until {}", + fmt::date(later.expect("checked")) + ), (_, Some(_)) => format!("book value {}", fmt::dollars(Decimal::ZERO)), (Ok(acc), None) => { format!("book value {}", fmt::dollars(a.cost.amount - *acc)) diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 8ba0336..413c0d6 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -65,7 +65,10 @@ fn attachment_name(b: &Books, id: u64) -> String { /// depreciable basis $1,440.00, built-in loss $248.40". The figures are /// ledger-core's (`Asset::basis_figures`) for the event's facts on the /// asset; a pre-P3 `asset.tax_basis_set` (`legacy`: its rule by its old -/// name, no value) reads the value as the book cost, as it was recorded. +/// name, no value) reads the value as the asset's book cost today, which +/// is the cost it was recorded against unless the cost was corrected since +/// (a correction moves a pre-P3 record's built-in gain, and the line shows +/// today's figure). fn tax_basis_text(b: &Books, asset_id: Option, p: &Value, legacy: bool) -> String { let asset = asset_id.and_then(|id| b.ledger.get_asset(id)); let name = match (asset, asset_id) { diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index c2b3a54..779f232 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -1148,17 +1148,20 @@ fn aging( /// The fixed asset register on `as_of`: ledger-core's /// `fixed_asset_register`, laid out, with its reconciliation to the -/// general ledger and what that says (the Assets page's notices). Each -/// asset's federal tax basis sits beside its book figures: contributor's -/// basis, tax basis, and built-in gain (loss), the register row's own, -/// with its rule (and citation) in the detail; the tax basis total is the +/// general ledger and what that says (the Assets page's notices). The +/// book figures in the Assets page's order (cost, posted, book value = +/// cost less posted, then the schedule's figure); beside them each +/// asset's tax figures at contribution, the register row's own: §723 +/// basis, depreciable basis and §704(c) built-in gain (loss), with its +/// rule (and citation), value at contribution, method and, for a built-in +/// loss, the IRC §704(c)(1)(C) note in the detail; the totals are the /// register's. fn asset_register(b: &Books, as_of: NaiveDate) -> Table { let mut t = Table::new(vec![ "Cost".into(), - "Scheduled".into(), "Posted".into(), "Book value".into(), + "Scheduled".into(), // Short enough for the columns: the note says what each is. "§723 basis".into(), "Depr. basis".into(), @@ -1217,6 +1220,12 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { ledger_import::asset_register::method_cited(m) )); } + if tax.built_in_gain_704c.is_some_and(|g| g < Decimal::ZERO) { + detail.push( + "a built-in loss counts only for the contributing member (IRC §704(c)(1)(C))" + .into(), + ); + } let opt = |v: Option| Cell { value: v, drill: None, @@ -1232,9 +1241,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { } None => vec![ Cell::of(x.cost), - Cell::of(x.accumulated_scheduled), Cell::of(x.accumulated_posted), Cell::of(x.book_value), + Cell::of(x.accumulated_scheduled), opt(tax.tax_basis_723), opt(tax.depreciable_basis), opt(tax.built_in_gain_704c), @@ -1252,9 +1261,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { "Total held", vec![ Cell::of(r.total_cost), - Cell::of(r.total_accumulated_scheduled), Cell::of(r.total_accumulated_posted), Cell::of(r.total_book_value), + Cell::of(r.total_accumulated_scheduled), Cell::of(r.total_tax_basis_723), Cell::of(r.total_depreciable_basis), Cell::none(), @@ -1292,11 +1301,11 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { value: Some(r.ledger_accumulated), drill: accum_drill, }, + Cell::of(r.ledger_cost - r.ledger_accumulated), Cell { value: Some(r.ledger_accumulated), drill: accum_drill, }, - Cell::of(r.ledger_cost - r.ledger_accumulated), Cell::none(), Cell::none(), Cell::none(), @@ -1307,9 +1316,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { "Register less books", vec![ Cell::of(-r.unregistered_cost()), - Cell::of(r.unposted_depreciation()), Cell::of(-r.unattributed_accumulated()), Cell::of(r.total_book_value - (r.ledger_cost - r.ledger_accumulated)), + Cell::of(r.unposted_depreciation()), Cell::none(), Cell::none(), Cell::none(), @@ -2441,9 +2450,9 @@ mod tests { t.columns, [ "Cost", - "Scheduled", "Posted", "Book value", + "Scheduled", "§723 basis", "Depr. basis", "Built-in gain" @@ -2462,9 +2471,9 @@ mod tests { v, [ Some(x.cost), - Some(x.accumulated_scheduled), Some(x.accumulated_posted), Some(x.book_value), + Some(x.accumulated_scheduled), f.tax_basis_723, Some(f.depreciable_basis), f.built_in_gain_704c, @@ -2476,7 +2485,9 @@ mod tests { let total = t.rows.iter().find(|row| row.label == "Total held").unwrap(); assert_eq!(total.cells[5].value, Some(r.total_depreciable_basis)); assert_eq!(total.cells[5].value, r.rows[0].depreciable_basis); - assert_eq!(total.cells[2].value, Some(r.total_accumulated_posted)); + assert_eq!(total.cells[1].value, Some(r.total_accumulated_posted)); + assert_eq!(total.cells[2].value, Some(r.total_book_value)); + assert_eq!(total.cells[3].value, Some(r.total_accumulated_scheduled)); assert!( t.rows .iter() diff --git a/crates/ledger-mcp/handbook/cpa-handoff.md b/crates/ledger-mcp/handbook/cpa-handoff.md index 2e26774..c01ff96 100644 --- a/crates/ledger-mcp/handbook/cpa-handoff.md +++ b/crates/ledger-mcp/handbook/cpa-handoff.md @@ -4,4 +4,4 @@ - Never file the package, or a file from it, into the books. - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. - Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, the memos grouped); tell the human. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one. -- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, and the reconciliation from book to tax capital). A line the books can't know (tax depreciation, the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in. +- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, a prepaid contract handed over at its book amount for the CPA to confirm, and the reconciliation from book to tax capital). A line the books can't know (the share of income as figured for tax, always; tax depreciation; a disposal's tax gain; the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in. diff --git a/crates/ledger-mcp/handbook/fixed-assets.md b/crates/ledger-mcp/handbook/fixed-assets.md index b2254ff..a88473f 100644 --- a/crates/ledger-mcp/handbook/fixed-assets.md +++ b/crates/ledger-mcp/handbook/fixed-assets.md @@ -6,5 +6,5 @@ - Two dates: the in-service date (in_service_date: the day it is ready and available for its use) starts depreciation; the acquired date is the date of the entry that put it on the books (its purchase or a member's contribution: give it as acquisition_entry, dated on or before the in-service date), and puts it on the register. Something bought before it is used is on the register from the purchase and depreciates from the day it serves. - Two accumulated figures, never confused: what the books carry (accumulated_posted: its depreciation runs, less any reversed) and what the schedule says is due by a date (accumulated_scheduled_through). Book value is cost less what the books carry; the difference is depreciation not yet run. - Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FA-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (give the human the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. -- Record every asset's tax basis beside its book value (books.set_asset_tax_basis, or basis_rule on books.register_asset): purchase for something bought (basis is its cost); for a member's contribution, carryover_723 (their business property) or personal_use_conversion (their personal property), with the member's adjusted basis (cost plus improvements, less depreciation allowed or allowable) and its fair market value when contributed: ask the human for both and who says so (basis_source), never assume the value is the cost. Three figures follow, each named for what it is: the §723 basis (the member's adjusted basis), the depreciable basis (that basis; for personal use, the lesser of it and the value), and the §704(c) built-in gain or loss (value less §723 basis; a loss counts only for the contributing member). They are at contribution; tax depreciation, and what remains of the gain or loss later, are the CPA's. Record the §704(c) method (traditional, traditional_with_curative, remedial) once chosen with the CPA. Book value stays fair market value. +- Record every asset's tax basis beside its book value (books.set_asset_tax_basis, or basis_rule on books.register_asset): purchase for something bought (basis is its cost); for a member's contribution, carryover_723 (their business property) or personal_use_conversion (their personal property), with the member's adjusted basis (cost plus improvements, less depreciation allowed or allowable) and its fair market value when contributed: ask the human for both and who says so (basis_source), never assume the value is the cost. Three figures follow, each named for what it is: the §723 basis (the member's adjusted basis), the depreciable basis (that basis; for personal use, the lesser of it and the value), and the §704(c) built-in gain or loss (value less §723 basis; a loss counts only for the contributing member). They are at contribution; tax depreciation, and what remains of the gain or loss later, are the CPA's. Record the §704(c) method (traditional, traditional_with_curative, remedial) once chosen with the CPA. The book cost of a contribution is its fair market value at contribution; book value is that cost less the depreciation posted. - books.export fixed_asset_register is the register for the CPA (posted and scheduled, every tax figure, reconciled to the books); the CPA package's k1-tax-basis-capital.csv carries each member's capital on the tax basis beside the book figures, with what only the CPA can supply left open. books.show asset opens one in the viewer. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 9e0854d..3b62642 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1438,16 +1438,19 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { } /// A parameter and its deprecated alias (one release): the value given, -/// noting when it came by the alias; both given and different is refused. +/// noting when it came by the alias; both given and meaning different +/// values (by `same`: an amount, a day or a rule however written) is +/// refused. fn aliased<'a>( name: &str, value: Option<&'a str>, alias: &str, old: Option<&'a str>, notes: &mut Vec, + same: fn(&str, &str) -> bool, ) -> Result, String> { match (value, old) { - (Some(v), Some(o)) if v != o => Err(format!( + (Some(v), Some(o)) if !same(v, o) => Err(format!( "{name} {v:?} and its old name {alias} {o:?} disagree: give {name} only" )), (Some(v), _) => Ok(Some(v)), @@ -1459,6 +1462,35 @@ fn aliased<'a>( } } +/// Two amounts given for one figure mean the same (`"100"` and +/// `"100.00"`); text that isn't an amount compares as text. +fn same_amount(a: &str, b: &str) -> bool { + match (a.trim().parse::(), b.trim().parse::()) { + (Ok(x), Ok(y)) => x == y, + _ => a == b, + } +} + +/// Two days given for one date mean the same. +fn same_day(a: &str, b: &str) -> bool { + match (parse_date(a), parse_date(b)) { + (Ok(x), Ok(y)) => x == y, + _ => a == b, + } +} + +/// Two basis rules mean the same, by wire or pre-P3 name +/// (`"carryover"` and `"carryover_723"`). +fn same_rule(a: &str, b: &str) -> bool { + let rule = |r: &str| { + ledger_core::BasisRule::parse(r).or_else(|| ledger_core::BasisRule::parse_legacy(r)) + }; + match (rule(a), rule(b)) { + (Some(x), Some(y)) => x == y, + _ => a == b, + } +} + /// A basis rule from a tool: its wire name, or its pre-P3 name (an alias /// for one release, noted). fn parse_basis_rule(rule: &str, notes: &mut Vec) -> Result { @@ -5609,7 +5641,7 @@ impl BooksServer { #[tool( name = "books.correct_asset", title = "Correct a fixed asset", - description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links, and basis record stay (a contribution's value and built-in gain are as contributed); correct the entry that put it on the books too, so the books and the register agree (books.export fixed_asset_register shows any difference). Journaled with the old figures.", + description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links and basis record stay (a recorded value at contribution never moves; a pre-P3 basis reads it as the book cost, so its built-in gain moves, noted); correct the entry that put it on the books too (books.export fixed_asset_register shows any difference). Journaled with the old figures.", annotations( read_only_hint = false, destructive_hint = true, @@ -5628,6 +5660,7 @@ impl BooksServer { "in_service", p.in_service.as_deref(), &mut notes, + same_day, )?; let mut store = self.lock()?; let id = resolve_asset_ref(&store, &p.asset)?; @@ -5648,11 +5681,19 @@ impl BooksServer { None => a.in_service_date, }, }; + let before = a.built_in_gain_704c(); store .correct_asset(id, fix, &p.reason) .map_err(|e| e.to_string())?; let ledger = store.ledger(); let a = ledger.get_asset(id).expect("resolved"); + if a.built_in_gain_704c() != before { + notes.push(format!( + "its basis was recorded before P3 with no value at contribution, which reads as the book cost, so its §704(c) built-in gain moved with the cost ({} to {}): record the value at contribution with books.set_asset_tax_basis", + before.map_or("none".into(), money), + a.built_in_gain_704c().map_or("none".into(), money) + )); + } Ok(Json(AssetSummaryOne { asset_id: id, tag: a.tag.clone(), @@ -5691,6 +5732,7 @@ impl BooksServer { "contributor_basis", p.contributor_basis.as_deref(), &mut notes, + same_amount, )?; let basis = parse_asset_basis( &p.rule, @@ -6256,6 +6298,7 @@ impl BooksServer { "purchase_date", p.purchase_date.as_deref(), &mut notes, + same_day, )? .ok_or("in_service_date is required: the day it was placed in service")?, )?; @@ -6316,6 +6359,7 @@ impl BooksServer { "tax_basis_rule", p.tax_basis_rule.as_deref(), &mut notes, + same_rule, )?; let adjusted = aliased( "contributor_adjusted_basis", @@ -6323,6 +6367,7 @@ impl BooksServer { "contributor_basis", p.contributor_basis.as_deref(), &mut notes, + same_amount, )?; let basis = match rule { Some(rule) => Some(parse_asset_basis( @@ -9235,7 +9280,7 @@ impl BooksServer { .map_err(|e| e.to_string()); } let mut csv = String::from( - "tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_method,basis_source\n", + "tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_1c,sec704c_method,basis_source\n", ); for a in &r.rows { let cells = [ @@ -9266,6 +9311,11 @@ impl BooksServer { a.tax_basis_723.map(money).unwrap_or_default(), a.depreciable_basis.map(money).unwrap_or_default(), a.built_in_gain_704c.map(money).unwrap_or_default(), + // IRC §704(c)(1)(C): a built-in loss counts only for the + // contributor; blank for a purchase or no basis. + a.built_in_gain_704c + .map(|g| (g < Decimal::ZERO).to_string()) + .unwrap_or_default(), a.sec704c_method .map(|m| m.key()) .unwrap_or_default() @@ -9276,7 +9326,7 @@ impl BooksServer { csv.push('\n'); } csv.push_str(&format!( - "TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,", + "TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,,", money(r.total_cost), money(r.total_accumulated_scheduled), money(r.total_accumulated_posted), diff --git a/crates/ledger-mcp/tests/spec_assets.rs b/crates/ledger-mcp/tests/spec_assets.rs index c0f1d77..92e06b1 100644 --- a/crates/ledger-mcp/tests/spec_assets.rs +++ b/crates/ledger-mcp/tests/spec_assets.rs @@ -1079,6 +1079,34 @@ fn basis_rule_legacy_names_are_aliases() { let _ = t2; } +/// A name and its alias that mean the same, however written, agree (P3 +/// review, 2026-10-21: they were compared as text, so "100" and "100.00", +/// or carryover and carryover_723, were refused as disagreeing). +#[test] +fn a_name_and_its_alias_that_mean_the_same_agree() { + let mut m = books("alias-same"); + let (_, t) = m.register_ok(json!({})); + let r = m.ok( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "900", + "contributor_basis": "900.00", "fmv_at_contribution": "1200.00"}), + ); + assert_eq!(r["basis"]["contributor_adjusted_basis"], "900.00", "{r}"); + m.register_ok( + json!({"basis_rule": "carryover_723", "tax_basis_rule": "carryover", + "contributor_adjusted_basis": "400.00", "contributor_basis": "400", + "fmv_at_contribution": "1200.00"}), + ); + m.register_ok(json!({"basis_rule": "purchase", "tax_basis_rule": "cost"})); + assert_eq!(m.assets().len(), 3); + let why = m.refuse( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "900.00", + "contributor_basis": "900.01", "fmv_at_contribution": "1200.00"}), + ); + assert!(why.contains("disagree"), "{why}"); +} + /// A contribution's fair market value is a fact the human gives, never /// assumed from the cost (review R11): without it the call is refused. #[test] @@ -1507,6 +1535,7 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() { "tax_basis_723", "depreciable_basis", "built_in_gain_704c", + "sec704c_1c", "sec704c_method", "basis_source" ] @@ -1526,7 +1555,14 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() { assert_eq!(canon(&r[col("tax_basis_723")]), "650.25"); assert_eq!(canon(&r[col("built_in_gain_704c")]), "549.75"); assert_eq!(r[col("fmv_recorded")], "true"); + assert_eq!(r[col("sec704c_1c")], "false", "a built-in gain"); + assert_eq!( + csv_row("CONV-1")[col("sec704c_1c")], + "true", + "a built-in loss" + ); let r = csv_row("BOUGHT-1"); + assert_eq!(r[col("sec704c_1c")], "", "a purchase"); assert_eq!(canon(&r[col("accumulated_posted")]), "100"); assert_eq!(canon(&r[col("book_value")]), "1100"); assert_eq!(r[col("contributor_adjusted_basis")], ""); @@ -1731,3 +1767,54 @@ fn a_skipped_month_is_caught_up_and_said() { ); assert!(err.contains("can't be reversed"), "{err}"); } + +/// A basis recorded before P3 has no value at contribution and reads it as +/// the book cost, so correcting the cost moves its built-in gain: the +/// result says so (P3 review, 2026-10-21: the tool text said the built-in +/// gain was "as contributed" whatever the record). By hand: cost 1,200.00, +/// basis 400.00, gain 800.00; cost corrected to 1,100.00, gain 700.00. +#[test] +fn correcting_a_legacy_contributions_cost_says_its_built_in_gain_moved() { + let mut m = books("legacy-correct"); + let (id, t) = m.register_ok(json!({})); + let dir = m.books_dir.take().unwrap(); + let data = m.data.clone(); + drop(m); + let mut w = ledger_store::JournalWriter::open(&data.join("journal.jsonl")).unwrap(); + w.append( + "asset.tax_basis_set", + json!({"asset_id": id, "rule": "carryover", "contributor_basis": "400.00"}), + ) + .unwrap(); + drop(w); + let toml = dir.join("wanderbooks.toml"); + let mut m = Mcp::start(&dir, &toml); + m.books_dir = Some(dir); + assert_eq!(m.asset(id)["basis"]["built_in_gain_704c"], "800.00"); + let r = m.ok( + "books.correct_asset", + json!({"asset": t, "cost": "1100.00", "reason": "invoice misread"}), + ); + assert_eq!(r["basis"]["built_in_gain_704c"], "700.00", "{r}"); + let notes = r["notes"].to_string(); + assert!( + notes.contains("moved with the cost (800.00 to 700.00)"), + "{notes}" + ); + // A recorded value never moves, and says nothing. + m.ok( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "400.00", + "fmv_at_contribution": "1100.00"}), + ); + let r = m.ok( + "books.correct_asset", + json!({"asset": t, "cost": "1000.00", "reason": "invoice misread again"}), + ); + assert_eq!(r["basis"]["built_in_gain_704c"], "700.00", "{r}"); + assert!( + r.get("notes") + .is_none_or(|n| n.as_array().is_some_and(|a| a.is_empty())), + "{r}" + ); +} diff --git a/crates/ledger-package/src/k1_tax.rs b/crates/ledger-package/src/k1_tax.rs index fc637ad..5c14f5c 100644 --- a/crates/ledger-package/src/k1_tax.rs +++ b/crates/ledger-package/src/k1_tax.rs @@ -26,7 +26,7 @@ use crate::text::{csv_escape, money}; use ledger_core::Ledger; -use ledger_core::tax_capital::{Open, TaxCapital, TaxLine}; +use ledger_core::tax_capital::{Note, Open, TaxCapital, TaxLine}; use rust_decimal::Decimal; /// The file's name in the package. @@ -73,10 +73,24 @@ pub fn open_text(ledger: &Ledger, o: &Open) -> String { Open::Disposal { asset: id, proceeds, - } => format!( - "the tax gain or loss on {}'s disposal (proceeds {}), from its adjusted tax basis", - asset(*id), - money(*proceeds) + built_in_gain_704c, + } => match built_in_gain_704c { + Some(g) if !g.is_zero() => format!( + "the tax gain or loss on {}'s disposal (proceeds {}), from its adjusted tax basis; it is contributed property with a built-in {} of {} at contribution, which the gain or loss must take account of, allocated to the contributing member(s) (IRC §704(c)(1)(A); Treas. Reg. §1.704-3)", + asset(*id), + money(*proceeds), + if *g < Decimal::ZERO { "loss" } else { "gain" }, + money(g.abs()) + ), + _ => format!( + "the tax gain or loss on {}'s disposal (proceeds {}), from its adjusted tax basis", + asset(*id), + money(*proceeds) + ), + }, + Open::TaxNetIncome { book } => format!( + "the member's share of net income (loss) as figured for tax purposes, from the return ({} on the books; every book-tax difference, Schedule M-1, is the CPA's)", + money(*book) ), Open::OtherChange { book } => format!( "the tax treatment of a {} change in book capital the books can't explain", @@ -85,6 +99,17 @@ pub fn open_text(ledger: &Ledger, o: &Open) -> String { } } +/// What a note says: a figure taken on a stated rule, for the CPA to +/// confirm. +pub fn note_text(n: &Note) -> String { + match n { + Note::PrepaidAtBook { entry, book } => format!( + "{} of it is a prepaid contract handed over in entry {entry}, taken at its book amount as its basis (IRC §723 carryover; the CPA to confirm)", + money(*book) + ), + } +} + /// The worksheet (INVARIANTS 1-3). `names` are the members' names, in the /// rows' order. pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> String { @@ -101,13 +126,18 @@ pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> Stri }; for (row, name) in tc.rows.iter().zip(names) { let line = |book: Decimal, tax: &TaxLine, note: &str| -> [String; 5] { + let note: String = std::iter::once(note.to_string()) + .filter(|n| !n.is_empty()) + .chain(tax.notes.iter().map(note_text)) + .collect::>() + .join("; "); match tax.tax() { Some(t) => [ money(book), money(t), money(book - t), "tax basis".into(), - note.into(), + note, ], None => { let needs: Vec = @@ -118,9 +148,14 @@ pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> Stri String::new(), "open: the CPA's".into(), format!( - "known so far {}; needs {}", + "known so far {}; needs {}{}", money(tax.known), - needs.join("; ") + needs.join("; "), + if note.is_empty() { + String::new() + } else { + format!("; {note}") + } ), ] } @@ -130,6 +165,7 @@ pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> Stri let cash = TaxLine { known: row.contributions_cash, open: Vec::new(), + notes: Vec::new(), }; push( name, @@ -153,11 +189,7 @@ pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> Stri push( name, "Current year net income (loss)", - line( - b.net_income, - &row.net_income, - "the book figure: the books know of no difference from it (any other, Schedule M-1, is the CPA's)", - ), + line(b.net_income, &row.net_income, ""), ); push( name, @@ -231,3 +263,50 @@ pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> Stri } w } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn d(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() + } + + /// A disposal of contributed property names its built-in gain or loss + /// at contribution and §704(c)(1)(A); a purchase's doesn't. A prepaid + /// taken at book says so, for the CPA to confirm. + #[test] + fn disposal_and_prepaid_texts_say_what_the_cpa_needs() { + let l = Ledger::new(ledger_core::Currency::USD); + let sold = |g: Option<&str>| { + open_text( + &l, + &Open::Disposal { + asset: 7, + proceeds: d("1100.00"), + built_in_gain_704c: g.map(d), + }, + ) + }; + let gain = sold(Some("300.00")); + assert!(gain.contains("built-in gain of 300.00"), "{gain}"); + assert!(gain.contains("IRC §704(c)(1)(A)"), "{gain}"); + let loss = sold(Some("-300.00")); + assert!(loss.contains("built-in loss of 300.00"), "{loss}"); + for none in [sold(None), sold(Some("0"))] { + assert!(!none.contains("§704(c)"), "{none}"); + assert!( + none.contains("asset 7's disposal (proceeds 1100.00)"), + "{none}" + ); + } + let n = note_text(&Note::PrepaidAtBook { + entry: 3, + book: d("20.00"), + }); + assert!(n.contains("20.00") && n.contains("entry 3") && n.contains("the CPA to confirm")); + let i = open_text(&l, &Open::TaxNetIncome { book: d("-5.00") }); + assert!(i.contains("from the return (-5.00 on the books"), "{i}"); + } +} diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 83608c8..7af5c13 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -641,7 +641,7 @@ pub fn build_package( } ); k.push_str(if k1_tax_included { - "k1-tax-basis-capital.csv: each member's capital account on the tax basis, as Item L reports it (Form 1065 instructions: the tax-basis method), line by line beside the book figures: cash contributed, property at its adjusted basis (IRC §723), the share of income as figured for tax, distributions at tax basis, and the reconciliation from book to tax capital (the §704(c) built-in gain or loss of contributed property). A line the books can't know (tax depreciation, a basis the books don't hold) is left blank, marked open, and says what the books know so far and what is needed.\n" + "k1-tax-basis-capital.csv: each member's capital account on the tax basis, as Item L reports it (Form 1065 instructions: the tax-basis method), line by line beside the book figures: cash contributed, property at its adjusted basis (IRC §723; a prepaid contract handed over at its book amount, the CPA to confirm), distributions at tax basis, and the reconciliation from book to tax capital (the §704(c) built-in gain or loss of contributed property). A line the books can't know is left blank, marked open, and says what the books know so far and what is needed: the share of income as figured for tax (always the return's), so the ending too; tax depreciation; the tax gain on a disposal (with a contributed property's built-in gain or loss); a basis the books don't hold.\n" } else { "k1-tax-basis-capital-NOT-INCLUDED.txt: why the tax-basis capital worksheet isn't here.\n" }); diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 859bfc5..833c75c 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -2241,6 +2241,7 @@ fn the_k1_worksheet_reports_tax_basis_capital() { "member,item,book,tax_basis,book_less_tax,status,note" ); assert_eq!(lines.len(), 1 + 2 * 12, "{csv}"); + let income_note = "the member's share of net income (loss) as figured for tax purposes, from the return (-385.00 on the books; every book-tax difference, Schedule M-1, is the CPA's)"; let depreciation_note = "tax depreciation of the fixed assets in service (MACRS, IRC §168), in place of the book depreciation posted (1370.00 in the span, 1370.00 of it on contributed property, whose tax depreciation each property's §704(c) method allocates, Treas. Reg. §1.704-3)"; for member in ["Tobin Example", "Mo Example"] { for want in [ @@ -2250,14 +2251,14 @@ fn the_k1_worksheet_reports_tax_basis_capital() { "{member},Capital contributed: property,1370.00,1384.20,-14.20,tax basis,at the property's adjusted basis (IRC §723); book: its fair market value" ), format!( - "{member},Current year net income (loss),-385.00,,,open: the CPA's,\"known so far -385.00; needs {depreciation_note}\"" + "{member},Current year net income (loss),-385.00,,,open: the CPA's,\"known so far -385.00; needs {income_note}; {depreciation_note}\"" ), format!("{member},Other increase (decrease),0.00,0.00,0.00,tax basis,"), format!( "{member},Withdrawals and distributions,300.00,300.00,0.00,tax basis,\"cash, plus the adjusted tax basis of property distributed\"" ), format!( - "{member},Ending capital,1685.00,,,open: the CPA's,\"known so far 1699.20; needs {depreciation_note}\"" + "{member},Ending capital,1685.00,,,open: the CPA's,\"known so far 1699.20; needs {income_note}; {depreciation_note}\"" ), format!( "{member},\"Reconciliation: beginning capital, book less tax\",,,0.00,reconciliation," diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 730e857..eee0668 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 13; +pub const SCHEMA_VERSION: u32 = 14; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -326,18 +326,15 @@ CREATE TABLE IF NOT EXISTS entry_approvals( "#, r#" -- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and --- 23; D-40), and assets' basis facts with their depreciation runs keyed --- by entry (plan P3, defect 13; D-41). One migration: none of P3 had --- shipped when they met. --- --- Each 1099 decision recorded with who decided (journal feature --- vendor_1099_decisions), line by line: the date it takes effect (NULL: --- from the beginning), nec, misc, not_reportable or NULL (undetermined), --- and for not_reportable its kind and reason. A decision from the --- beginning also sets vendors.form_1099, as every decision written before --- these did. Payment methods (feature payment_instruments) and the method --- each entry was paid with. Rows only come from lines written under the --- features, so books written before them project the same as before. +-- 23; D-40). Each 1099 decision recorded with who decided (journal +-- feature vendor_1099_decisions), line by line: the date it takes effect +-- (NULL: from the beginning), nec, misc, not_reportable or NULL +-- (undetermined), and for not_reportable its kind and reason. A decision +-- from the beginning also sets vendors.form_1099, as every decision +-- written before these did. Payment methods (feature payment_instruments) +-- and the method each entry was paid with. Rows only come from lines +-- written under the features, so books written before them project the +-- same as before. CREATE TABLE IF NOT EXISTS vendor_1099_decisions( seq INTEGER PRIMARY KEY, vendor_id INTEGER NOT NULL, @@ -359,6 +356,13 @@ CREATE TABLE IF NOT EXISTS entry_instruments( entry_id INTEGER PRIMARY KEY, instrument_id INTEGER NOT NULL ); +"#, + r#" +-- v14: assets' basis facts and their depreciation runs keyed by entry +-- (plan P3, defect 13; D-41). Its own migration: v13 shipped on main +-- before these columns, so a v13 file already built never gains them by +-- an edit to v13 (each schema's file is rebuilt from the journal, and a +-- migration's text never changes once shipped). -- -- An asset's basis facts beyond the rule and contributor basis -- (asset.basis_set): the fair market value at contribution (TEXT, exact diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index 6985a62..dcdd097 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -18,7 +18,7 @@ use chrono::NaiveDate; use ledger_core::{AccountType, AssetId, Currency, LedgerError, Money, NewAsset}; use ledger_store::journal::read_and_verify_journal; -use ledger_store::{JOURNAL_FILE, JournalWriter, Store, StoreError, db_file}; +use ledger_store::{JOURNAL_FILE, JournalWriter, Store, StoreError, db_file, db_file_for}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; @@ -887,6 +887,49 @@ fn downgrade_db_to_v12(db: &Path) { .unwrap(); } +/// A database at v13 exactly as it shipped on main before the assets' +/// basis columns (P3 review, 2026-10-21: those columns were first written +/// into v13 itself, so a v13 file built by the earlier commits opened as +/// current and failed to load, "no such column"): its file is another +/// schema's, left byte for byte, and this version builds its own from the +/// journal, with every run, and verifies. +#[test] +fn a_v13_file_built_before_the_basis_columns_is_left_and_rebuilt() { + use ledger_core::BasisRule::*; + let (dir, mut s, _, x, y) = fixture("v13-shipped"); + s.run_depreciation(x, "2026-01").unwrap(); + s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00")) + .unwrap(); + drop(s); + let v13 = dir.join(db_file_for(13)); + std::fs::rename(dir.join(db_file()), &v13).unwrap(); + let conn = rusqlite::Connection::open(&v13).unwrap(); + conn.execute_batch( + "ALTER TABLE assets DROP COLUMN fmv_at_contribution; + ALTER TABLE assets DROP COLUMN sec704c_method; + ALTER TABLE assets DROP COLUMN basis_source_json; + CREATE TABLE asset_depreciation_v12(asset_id INTEGER NOT NULL, period TEXT NOT NULL, + entry_id INTEGER NOT NULL, amount TEXT NOT NULL, PRIMARY KEY (asset_id, period)); + INSERT INTO asset_depreciation_v12 SELECT asset_id, period, entry_id, amount FROM asset_depreciation; + DROP TABLE asset_depreciation; + ALTER TABLE asset_depreciation_v12 RENAME TO asset_depreciation; + UPDATE meta SET value = '13' WHERE key = 'schema_version';", + ) + .unwrap(); + drop(conn); + let shipped = std::fs::read(&v13).unwrap(); + let s = Store::open(&dir).expect("books with a shipped v13 file open"); + assert_eq!(s.database_file(), db_file()); + s.verify().unwrap(); + assert_eq!(s.ledger().depreciation_runs(x).len(), 1); + assert_eq!( + basis_of(&s, y), + Some(contribution(Carryover723, "640.00", "1200.00")) + ); + drop(s); + assert_eq!(std::fs::read(&v13).unwrap(), shipped, "left byte for byte"); +} + /// Downgrade the database to schema v10 (none of v11's attachment links, /// statements, reconciliations, or payroll_lines.employee_id). fn downgrade_db_to_v10(dir: &Path) { @@ -909,12 +952,12 @@ fn downgrade_db_to_v10(dir: &Path) { } /// P2's database (`wanderbooks.db`, schema v12) is left as it is, byte -/// for byte: this version builds its own v13 file from the journal, its +/// for byte: this version builds its own file from the journal, its /// runs table keyed by entry with every run kept and the new columns /// empty, and it verifies; a basis set after then reloads from it (each /// schema in a file of its own: rules.md B-27). #[test] -fn a_p2_database_is_left_and_v13_built_from_the_journal() { +fn a_p2_database_is_left_and_this_versions_built_from_the_journal() { use ledger_core::BasisRule::*; let (dir, mut s, _, x, y) = fixture("v12"); s.run_depreciation(x, "2026-01").unwrap(); @@ -1213,3 +1256,35 @@ fn the_viewer_and_writer_agree_on_a_bad_depreciation_posted_payload() { } } } + +/// B-27: books an older version wrote (format 1, declaring nothing) refuse +/// a basis while they wait for the owner's format upgrade, a purchase's +/// too, journaling nothing; the feature is never declared on the way (P3 +/// review, 2026-10-21). +#[test] +fn a_basis_in_books_pending_their_upgrade_is_refused_and_journals_nothing() { + use ledger_core::BasisRule::*; + let dir = test_dir("basis-pending"); + let mut s = Store::create_in_format( + &dir, + Currency::USD, + &ledger_store::format::JournalFormat::default(), + ) + .unwrap(); + let a = accounts(&mut s); + let x = laptop(&mut s, &a, "Laptop A"); + let was = std::fs::read(dir.join(JOURNAL_FILE)).unwrap(); + for b in [ + ledger_core::AssetBasis::purchase(), + contribution(Carryover723, "640.00", "1200.00"), + ] { + match s.set_asset_basis(x, b) { + Err(StoreError::FormatUpgradePending { need }) => { + assert!(need.contains(ledger_store::format::ASSET_BASIS), "{need:?}") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + } + assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), was); + assert_eq!(basis_of(&s, x), None); +} diff --git a/crates/ledger-store/tests/schema_files.rs b/crates/ledger-store/tests/schema_files.rs index 73b403a..0094568 100644 --- a/crates/ledger-store/tests/schema_files.rs +++ b/crates/ledger-store/tests/schema_files.rs @@ -369,3 +369,36 @@ fn new_books_get_their_schemas_own_file() { assert_eq!(ledger_store::is_database_file(name), is, "{name}"); } } + +/// A shipped migration's text never changes (P3 review, 2026-10-21: the +/// assets' v13 columns were first added to the v13 already on main, so a +/// v13 file built before them would open without them). Each schema's +/// file is built once by these scripts; a change is a new migration. The +/// SHA-256 of each shipped one, v1 to v13 (append the next as it ships). +#[test] +fn a_shipped_migration_never_changes() { + use sha2::{Digest, Sha256}; + const SHIPPED: [&str; 13] = [ + "8b253dded247d8518a7454b1c894a147ac211ee32ea2671dca2c586c5184c046", + "9fc19f844ed33b8f08eee8437ee3eb038e854737d5febe79ce3ac80abeb18614", + "215af9a7a54cda382f721488fedb34f68eb36399730de1b23371d44eb16fd79d", + "7923eb31ab8a08f20b8d7df017814095861478cb796af14994dbb85dbe558c20", + "95caa91ee3327b5590ad1ddefa76bad189950091cfcd140121f978e0af258e8a", + "74181a819beb4349808b9a095fbcda0073890fa6f54bc3fa5535c5a6644e35c6", + "df008d2a7cf832173908d1e33f795b88d272eae9cd23a5ebebe7207871d7076f", + "d0cd600ef4aa7bc2301e682bace23f3ce311b5f991e5d7bff447a9520ca8e89f", + "b2e54e8cf47b7f55f42f3fd4876fce5183e229aea08059518a50cbbffdca2db2", + "8881527992c586f69644276db921e640fb6548ee1d2f7c313a46c53e1e63430e", + "cc18b31e929b5889c0d09190b3211ccefd9d73df8e1e05c93f9e460097e1553a", + "dd2a93d760350f2edd9462f4a52888c0645040582739d3a93b17a3195238d501", + "9aa8a0ea7ea15315a4e38281e3a6d4e14beb26333991ebb2824d9c8cc5a51941", + ]; + for (i, want) in SHIPPED.iter().enumerate() { + let got: String = Sha256::digest(MIGRATIONS[i].as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!(&got, want, "migration v{} changed after it shipped", i + 1); + } + assert_eq!(MIGRATIONS.len() as u32, SCHEMA_VERSION); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 4b73cbe..e781143 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -605,7 +605,7 @@ life's last day is 9999-12-31 at the latest. name's initials without LLC/Inc; "FA" when none). `in_service_date` (the day it is ready and available for its use) starts depreciation (P3, defect 13; B-15); `purchase_date` is its alias for one release (both - given and different: refused; the alias noted in `notes`). The + given and meaning different days: refused; the alias noted in `notes`). The `acquisition_entry` (posted, debiting the cost account, dated on or before the in-service date) gives `acquired_date`, which puts the asset on the register; with none, the acquired date is the in-service date. @@ -632,11 +632,16 @@ life's last day is 9999-12-31 at the latest. `accumulated_posted` (what the books carry: its depreciation runs, less reversals, a reversal of a reversal reinstating), `accumulated_scheduled_through` (what the schedule says is due by - `as_of`), `unposted_depreciation` (the difference: run - `books.run_depreciation`), `book_value` (cost less posted: what the - books carry), and `basis` (as `books.set_asset_tax_basis` reports it). - Disposed by `as_of`: every figure 0.00; acquired after `as_of`: the - figures null. The rows are the register's (`fixed_asset_register`). An + `as_of`), `unposted_depreciation` (the difference for this asset, its + schedule less its runs: run `books.run_depreciation`; the register's + total `unposted_depreciation` compares the schedules with the general + ledger instead, so a hand entry in accumulated depreciation shows there, + as `unattributed_accumulated`, not here), `book_value` (cost less + posted: what the books carry), and `basis` (as + `books.set_asset_tax_basis` reports it). `cost` is always the + capitalized cost; `held` says whether the books carry it. Disposed by + `as_of`: every other figure 0.00; acquired after `as_of`: those figures + null. The rows are the register's (`fixed_asset_register`). An asset whose figures can't be computed (one an earlier version took) lists with them null and `schedule_error` saying why: one bad asset never fails the list. @@ -675,6 +680,9 @@ life's last day is 9999-12-31 at the latest. Refused while the books carry depreciation for it (a run posted and not reversed), and for an in-service date before its acquisition entry's date; `in_service` is the alias of `in_service_date` for one release. + A recorded value at contribution, and so the built-in gain, never + moves; a basis recorded before P3 has none and reads it as the book + cost, so a cost correction moves its built-in gain, and `notes` says so. - `books.run_depreciation { period: "YYYY-MM", asset_id? }` → `runs: [{ asset_id, description, period, amount, caught_up, entry_id, already_posted }]` and `skipped: [{ asset_id, description, reason }]`. @@ -731,7 +739,9 @@ life's last day is 9999-12-31 at the latest. salvage, useful_life_months, accumulated_scheduled, accumulated_posted, book_value, status, proceeds, basis_rule, contributor_adjusted_basis, fmv_at_contribution, fmv_recorded, tax_basis_723, depreciable_basis, - built_in_gain_704c, sec704c_method, basis_source`, then a TOTAL row. + built_in_gain_704c, sec704c_1c, sec704c_method, basis_source`, then a + TOTAL row; `sec704c_1c` is `true` for a built-in loss (IRC + §704(c)(1)(C)), blank without a contribution basis. - `books.show { asset }` opens an asset in the viewer; `page: "assets"` and `report: "fixed_asset_register"` open the register. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 4c2a568..9e99d80 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -234,8 +234,8 @@ earnings the same running way, D-38, 2026-10-20) analysis "is based on the partnership's books and records" (the Partner's Instructions' Basis Limitations section, `sources/irs-partnerships.md`; not Item L, which an earlier version of this rule spliced in). The K-1 - worksheet is the book capital analysis, which the CPA converts for - Item L. + worksheet carries the book capital analysis, and beside it the tax-basis + capital as far as the books know it (B-36). - **No authority** says how to round a share to the cent. Largest remainder, ties to the member listed first: DECISION (the owner, 2026-10-13). Closes made before then replay with the last member @@ -2467,20 +2467,29 @@ book capital reconciles to tax capital: gain or loss at contribution, shared within the entry as its book value is (B-13). Property contributed with no basis in the books (no registered asset linked to the entry holds a contribution basis for it) - is open. -- What the books can't know is never guessed: the tax depreciation of - every fixed asset in service (MACRS, and for contributed property the - §704(c) method's allocation: the CPA's, B-15), the tax gain on a - disposal, the adjusted tax basis of property distributed, the tax - treatment of a change in book capital the books can't explain, and - last year's tax ending when an earlier year left a figure open. Each - such line is left blank and marked open, with what the books know so - far and the book amount the CPA's figure replaces. The book - depreciation the tax figure replaces is the period's depreciation - expense, the P&L's: each asset's runs and what its disposal entry posts - to its expense account (B-15 rule 8; before the seam review the runs - alone, so a disposal that credited back part of a month's run stated - more than the P&L). + is open. Property contributed to a prepaid account (the balance + account of a prepaid-expense schedule: a service contract the member + handed over, B-13) with no fixed asset for it enters at its book + amount, with a note: a contract's carryover basis is what the books + recorded for it (DECISION, P3 review 2026-10-21; the CPA to confirm). +- What the books can't know is never guessed. The share of net income + "as figured for tax purposes" is the return's, always: the books are + kept on their own basis, and every book-tax difference (Schedule M-1) + is the CPA's; so is the ending, and the beginning of any year after + the first (Item L: "the partner's ending capital account as + determined for last year"). The line names the differences the books + do see: the tax depreciation of every fixed asset in service (MACRS, + and for contributed property the §704(c) method's allocation: the + CPA's, B-15), and the tax gain on a disposal, with a contributed + property's built-in gain or loss at contribution, which that gain must + take account of (IRC §704(c)(1)(A)). Also open: the adjusted tax basis + of property distributed, and the tax treatment of a change in book + capital the books can't explain. Each such line is left blank and + marked open, with what the books know so far and the book amount the + CPA's figure replaces. + The book depreciation the tax figure replaces is the period's + depreciation expense, the P&L's: each asset's runs and what its + disposal entry posts to its expense account (B-15 rule 8). - The reconciliation: book ending less tax ending (as far as the books know it) = beginning (book less tax) + the built-in gain or loss share + property without a basis (at book) − property distributed (at book), diff --git a/docs/spec/sources/irs-partnerships.md b/docs/spec/sources/irs-partnerships.md index c426716..64c56b4 100644 --- a/docs/spec/sources/irs-partnerships.md +++ b/docs/spec/sources/irs-partnerships.md @@ -61,7 +61,7 @@ Source: Retrieved: 2026-10-20 SHA-256 of the retrieved file (`i1065sk1.html`): `d7d6298286601fd95ab8e445c10fd62499ea1e37f8e5c3531f8e1783862d8d03` -**Item L, its first paragraph** (the partner's capital account, on the tax basis; the K-1 worksheet carries the book capital account, which the CPA converts) +**Item L, its first paragraph** (the partner's capital account, on the tax basis; the K-1 worksheet carries the book capital account and, beside it, the tax-basis one as far as the books know it, rules.md B-36) > The partnership must report your beginning capital account and ending capital account for the year using the tax-basis method, including the amount of capital you contributed to the partnership during the year, your share of the partnership’s current-year net income or loss as computed for tax purposes, any withdrawals and distributions made to you by the partnership, and any other increases or decreases to your capital account determined in a manner generally consistent with figuring the partner’s adjusted tax basis in its partnership interest (without regard to partnership liabilities), taking into account the rules and principles of sections 705, 722, 733, and 742. See the Instructions for Form 1065 for more details. diff --git a/docs/testing.md b/docs/testing.md index b362e30..07d6d4b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1579,8 +1579,14 @@ basis. Mutation runs wait for P12 (D-33); none was run. `record_depreciation_run`); `tax_capital.rs` (tax-basis capital per member with a cash contribution, a contribution of property, a distribution and a year of depreciation; next year's beginning; cash-only - books; property without a basis; property distributed; the roll-forward - and reconciliation over spans); `properties.rs` + books, whose tax income is still the return's; property without a basis; + property distributed; the roll-forward and reconciliation over spans; + and, from the P3 review, four devices and a prepaid plan by hand (two + worth less than basis, one more, one equal; the plan at book with its + note; the gain property's disposal flagged with its built-in gain), + property subject to a liability (negative tax property), and three + members with unequal shares, one contributing nothing); + `spec_asset_basis.rs` `a_duplicated_run_line_is_carried_once`; `properties.rs` `prop_basis_figures_laws` (5,000 cases); `kernel_bridge.rs` `basis_figures_agree`; Kani `basis_figures_laws` (`src/proofs.rs`, 83 checks, its cover satisfied); `spec_asset_tags.rs` (the register's @@ -1591,8 +1597,11 @@ basis. Mutation runs wait for P12 (D-33); none was run. line whose figures disagree with today's formula still replays; an unknown rule or method is newer, a malformed figure corrupt; pre-P3 `asset.tax_basis_set` lines give the figures they gave; P2's v12 - database left as it is while this version builds its v13 file, runs - keyed by entry (`a_p2_database_is_left_and_v13_built_from_the_journal`); + database left as it is while this version builds its v14 file, runs + keyed by entry (`a_p2_database_is_left_and_this_versions_built_from_the_journal`); + a v13 file as it shipped before the basis columns left too and v14 + built (`a_v13_file_built_before_the_basis_columns_is_left_and_rebuilt`; + `schema_files.rs` `a_shipped_migration_never_changes`); v9 and v10 databases under this version's name rebuilt). The old books (`books_an_earlier_version_wrote_open_to_its_figures`) open to exactly their figures.