diff --git a/bookspec/examples/partnership-k1-tax-basis-odd-cents.toml b/bookspec/examples/partnership-k1-tax-basis-odd-cents.toml new file mode 100644 index 0000000..ae1f08c --- /dev/null +++ b/bookspec/examples/partnership-k1-tax-basis-odd-cents.toml @@ -0,0 +1,167 @@ +# B-36 with D-38: the tax-basis K-1's odd cents run cumulatively. +id = "partnership-k1-tax-basis-odd-cents" +title = "Tax-basis capital: odd cents of contributed property's basis split cumulatively" +rule = "Within each contribution, the property's built-in gain or loss (value less adjusted tax basis) is shared among the members as its book value is; to the cent, each member's running share from the books' first entry MUST be the largest-remainder rounding of their exact running share (ties to the member listed first), less what earlier entries gave them, so no member's share is ever a cent from exact and tied odd cents alternate instead of piling up on one member." +status = "CHOICE" +sources = [ + "Instructions for Form 1065 (2025), Item L, Tax-basis method: Capital contributed during the year", + "26 U.S.C. §723", + "Treas. Reg. §1.704-1(b)(2)(iv)(b)", + "DECISION D-38 (owner, 2026-10-20): odd cents everywhere alike, cumulative largest remainder; applied to the tax-basis K-1 by the owner, 2026-10-22 (wanderbooks rules.md B-36, B-13)", +] +tags = ["partnerships", "k1", "tax-basis", "rounding"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.members]] +name = "Tobin Harlow" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "Morgan Ellis" +capital_account = "3001" +share = "0.5" + +# Three items the members contribute jointly, each from personal use and +# each worth less than its adjusted basis by an odd number of cents. +[[setup.assets]] +id = "monitor" +description = "Monitor" +cost = "400.00" +salvage = "0.00" +life_months = 24 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "412.37", fmv_at_contribution = "400.00", sec704c_method = "traditional" } + +[[setup.assets]] +id = "chair" +description = "Chair" +cost = "250.00" +salvage = "0.00" +life_months = 24 +in_service = "2026-06-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 2 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "263.41", fmv_at_contribution = "250.00", sec704c_method = "traditional" } + +[[setup.assets]] +id = "desk" +description = "Desk" +cost = "600.00" +salvage = "0.00" +life_months = 24 +in_service = "2026-11-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 3 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "655.91", fmv_at_contribution = "600.00", sec704c_method = "traditional" } + +[[events]] +date = "2026-01-01" +description = "Monitor contributed jointly, at its value" +lines = [ + { account = "1500", side = "debit", amount = "400.00" }, + { account = "3000", side = "credit", amount = "200.00" }, + { account = "3001", side = "credit", amount = "200.00" }, +] + +[[events]] +date = "2026-06-01" +description = "Chair contributed jointly, at its value" +lines = [ + { account = "1500", side = "debit", amount = "250.00" }, + { account = "3000", side = "credit", amount = "125.00" }, + { account = "3001", side = "credit", amount = "125.00" }, +] + +[[events]] +date = "2026-11-01" +description = "Desk contributed jointly, at its value" +lines = [ + { account = "1500", side = "debit", amount = "600.00" }, + { account = "3000", side = "credit", amount = "300.00" }, + { account = "3001", side = "credit", amount = "300.00" }, +] + +# By hand, the built-in loss shared as it runs (exact shares in brackets): +# Monitor, -12.37: running -12.37 (-6.185 each); rounded toward zero +# -6.18 each, a cent short, the remainders tie, the cent to Tobin: +# Tobin -6.19, Morgan -6.18. +# Chair, -13.41: running -25.78 (-12.89 each, exact): Tobin -12.89 less +# -6.19 given = -6.70; Morgan -12.89 less -6.18 = -6.71. +# Desk, -55.91: running -81.69 (-40.845 each): tied again, to Tobin: +# Tobin -40.85 less -12.89 = -27.96; Morgan -40.84 less -12.89 = +# -27.95. +# Year: built-in loss Tobin -40.85, Morgan -40.84; book property 625.00 +# each; tax property 625.00 + 40.85 = 665.85 and 625.00 + 40.84 = 665.84, +# a cent apart. Shared entry by entry (each entry's tied cent to Tobin) +# it was -40.86 / -40.83: 665.86 against 665.83, three cents apart. +[[expect]] +query = "k1_tax_basis" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.contributions_property" = "665.85", "Tobin Harlow.known_ending" = "665.85", "Morgan Ellis.contributions_property" = "665.84", "Morgan Ellis.known_ending" = "665.84" } + +[[expect]] +query = "k1_reconciliation" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.built_in_gain_704c" = "-40.85", "Tobin Harlow.property_without_basis" = "0.00", "Tobin Harlow.property_distributed" = "0.00", "Tobin Harlow.total" = "-40.85", "Morgan Ellis.beginning" = "0.00", "Morgan Ellis.built_in_gain_704c" = "-40.84", "Morgan Ellis.property_without_basis" = "0.00", "Morgan Ellis.property_distributed" = "0.00", "Morgan Ellis.total" = "-40.84" } + +# From June the count runs on from January: the beginning is January to +# May's ending, open (the monitor's tax depreciation is the preparer's), +# known so far Tobin 200.00 + 6.19 = 206.19, Morgan 206.18; the span +# takes the chair's and the desk's parts, -6.70 - 27.96 = -34.66 and +# -6.71 - 27.95 = -34.66: tax property 425.00 + 34.66 = 459.66 each, the +# ending the year's. +[[expect]] +query = "k1_tax_basis" +from = "2026-06-01" +to = "2026-12-31" +values = { "Tobin Harlow.beginning" = "open", "Tobin Harlow.known_beginning" = "206.19", "Tobin Harlow.contributions_property" = "459.66", "Tobin Harlow.known_ending" = "665.85", "Morgan Ellis.beginning" = "open", "Morgan Ellis.known_beginning" = "206.18", "Morgan Ellis.contributions_property" = "459.66", "Morgan Ellis.known_ending" = "665.84" } diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index 23d0243..1851d25 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -171,67 +171,29 @@ pub fn k1_detail( let places = ledger.base_currency().allocation_places(); let closings = ledger.closing_entry_ids(); let cancelled = crate::reports::cancelled_in_span(ledger, start, end); - let property_account = |id: AccountId| is_property_account(ledger, cash, id); - let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal { - e.lines() - .iter() - .filter(|l| l.side == side && l.account == account) - .map(|l| l.amount.amount) - .sum() - }; let mut lines = Vec::new(); for e in ledger .posted_entries() .filter(|e| e.date() >= start && e.date() <= end && !cancelled.contains(&e.id())) { - let line = |member, kind, amount| K1Line { - member, - entry: e.id(), - kind, - amount, - }; if closings.contains(&e.id()) { for (i, m) in members.iter().enumerate() { let effect: Decimal = std::iter::once(m.capital_account) .chain(m.distribution_account) - .map(|a| sum(e, Side::Credit, a) - sum(e, Side::Debit, a)) + .map(|a| side_sum(e, Side::Credit, a) - side_sum(e, Side::Debit, a)) .sum(); if !effect.is_zero() { - lines.push(line(i, K1Kind::IncomeClose, effect)); + lines.push(K1Line { + member: i, + entry: e.id(), + kind: K1Kind::IncomeClose, + amount: effect, + }); } } continue; } - let pool: Decimal = e - .lines() - .iter() - .filter(|l| l.side == Side::Debit && property_account(l.account)) - .map(|l| l.amount.amount) - .sum(); - // Contributions are credits to capital (members_equity's). - let credits: Vec = members - .iter() - .map(|m| sum(e, Side::Credit, m.capital_account)) - .collect(); - let takes = share_property(pool, &credits, places)?; - for (i, m) in members.iter().enumerate() { - let (credit, take) = (credits[i], takes[i]); - if !(credit - take).is_zero() { - lines.push(line(i, K1Kind::CashContribution, credit - take)); - } - if !take.is_zero() { - lines.push(line(i, K1Kind::PropertyContribution, take)); - } - // Distributions: debits to capital, net debits to the - // distributions account (members_equity's). - let taken = sum(e, Side::Debit, m.capital_account) - + m.distribution_account.map_or(Decimal::ZERO, |d| { - sum(e, Side::Debit, d) - sum(e, Side::Credit, d) - }); - if !taken.is_zero() { - lines.push(line(i, K1Kind::Distribution, -taken)); - } - } + lines.extend(entry_lines(ledger, e, members, cash, places)?); } let total = |i: usize, k: K1Kind| -> Decimal { lines @@ -266,6 +228,68 @@ pub fn k1_detail( Ok(K1Detail { rows, lines }) } +/// Σ of an entry's lines on `account`, on `side`. +fn side_sum(e: &crate::JournalEntry, side: Side, account: AccountId) -> Decimal { + e.lines() + .iter() + .filter(|l| l.side == side && l.account == account) + .map(|l| l.amount.amount) + .sum() +} + +/// One entry's contribution and distribution lines, as [`k1_detail`] +/// gives them for an entry that is not a period's closing entry +/// (INVARIANTS 3-5): they depend on the entry alone, never on the span, +/// so the tax-basis worksheet (crate::tax_capital) reads every entry's +/// the same way whatever span it is asked for. `places` is the base +/// currency's Currency::allocation_places. +pub(crate) fn entry_lines( + ledger: &Ledger, + e: &crate::JournalEntry, + members: &[MemberCapital], + cash: &[AccountId], + places: u32, +) -> Result, LedgerError> { + let line = |member, kind, amount| K1Line { + member, + entry: e.id(), + kind, + amount, + }; + let pool: Decimal = e + .lines() + .iter() + .filter(|l| l.side == Side::Debit && is_property_account(ledger, cash, l.account)) + .map(|l| l.amount.amount) + .sum(); + // Contributions are credits to capital (members_equity's). + let credits: Vec = members + .iter() + .map(|m| side_sum(e, Side::Credit, m.capital_account)) + .collect(); + let takes = share_property(pool, &credits, places)?; + let mut lines = Vec::new(); + for (i, m) in members.iter().enumerate() { + let (credit, take) = (credits[i], takes[i]); + if !(credit - take).is_zero() { + lines.push(line(i, K1Kind::CashContribution, credit - take)); + } + if !take.is_zero() { + lines.push(line(i, K1Kind::PropertyContribution, take)); + } + // Distributions: debits to capital, net debits to the + // distributions account (members_equity's). + let taken = side_sum(e, Side::Debit, m.capital_account) + + m.distribution_account.map_or(Decimal::ZERO, |d| { + side_sum(e, Side::Debit, d) - side_sum(e, Side::Credit, d) + }); + if !taken.is_zero() { + lines.push(line(i, K1Kind::Distribution, -taken)); + } + } + Ok(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. diff --git a/crates/ledger-core/src/tax_capital.rs b/crates/ledger-core/src/tax_capital.rs index 35209b1..85b9a70 100644 --- a/crates/ledger-core/src/tax_capital.rs +++ b/crates/ledger-core/src/tax_capital.rs @@ -23,10 +23,19 @@ //! 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. +//! among the members as its book value is (by their property in it), +//! to the cent by a running split from the books' first entry +//! ([`RunningSplit`], D-38's rule: each member's running share is +//! the largest-remainder rounding of their exact running share, so +//! it is never a cent from it and tied odd cents alternate rather +//! than all going 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. Property distributed is shared +//! among the members by what each took, the same running way. The +//! running split is kept per set of members with a part in the entry, +//! in date order whatever the span (`entry_parts`): a member never +//! takes a cent of an entry they had no part in, and an entry's cents +//! are the same in every span it is in. //! 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 @@ -314,53 +323,393 @@ pub fn tax_basis_capital( cash: &[AccountId], ) -> Result { // Beginning: the same worksheet from the books' first entry to the day - // before, whose ending is this span's beginning. + // before, whose ending is this span's beginning. The odd cents of + // every entry through `end`, whatever the span (INVARIANT 2). + let parts = entry_parts(ledger, end, members, cash)?; 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)?) + Some(span(ledger, first, before, members, cash, None, &parts)?) } _ => None, }; - span(ledger, start, end, members, cash, prior.as_ref()) + span(ledger, start, end, members, cash, prior.as_ref(), &parts) } 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, 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 = weights - .iter() - .map(|w| magnitude.checked_mul(*w).and_then(|x| x.checked_div(total))) - .collect::>() - .ok_or_else(too_large)?; - let mut parts: Vec = exact - .iter() - .map(|x| x.round_dp_with_strategy(2, ToZero)) - .collect(); - let remainders: Vec = 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; +/// A running split of amounts among members, one split at a time, each +/// by its own weights (B-36, the odd cent as D-38 settles it): each +/// member's running total is rounded afresh from their exact running +/// total, so it never drifts a cent from it, and tied cents don't pile up +/// on one member. The worksheet keeps one per set of members with weight +/// (`entry_parts`), so in its use every member of a split has weight. +/// +/// ```text +/// GOAL: no member's share of the property's built-in gain, of the +/// property without a basis, of the prepaid at book or of property +/// distributed is a cent or more from their exact share, however +/// many entries it is summed over; three equal odd-cent properties +/// contributed 50/50 leave the members one cent apart, never three. +/// SOURCE: rules.md B-36 (shared within each entry as its book value is), +/// B-13 (cent rounding by largest remainder: DECISION, owner +/// 2026-10-13); DECISION D-38 (owner, 2026-10-20: odd cents +/// everywhere alike, cumulative largest remainder), here for the +/// tax basis by the owner's ask, 2026-10-22 (kernel:: +/// apportion_cumulative, the members closes' rule). +/// INVARIANTS: +/// 1. The parts of each split sum to its whole exactly. +/// 2. A member's exact running total is Σ whole × weight / Σ weights +/// over every split so far; their running total (Σ of their parts) +/// is the largest-remainder rounding to the cent of the exact running +/// totals (ties to the earlier member), so it is within a cent of +/// exact, strictly. A negative running whole is rounded as its +/// magnitude and negated; a running whole of zero is rounded by the +/// sign of the whole just split. So a run of losses mirrors the run +/// of gains with every whole negated, part for part. +/// 3. A split can move a member who has no weight in it by a cent: the +/// true-up that keeps their running total one rounding. The +/// worksheet never gives one such a split: it keeps a running split +/// per set of members with weight. +/// 4. The exact shares are Decimal quotients (28 significant digits), +/// so a running total of thirds can come out a hair under the cent +/// it truly is (399.93 as 399.9299…9): each exact running total is +/// taken to EXACT_PLACES (10) decimal places before it is rounded to +/// the cent, far finer than any remainder that decides a cent and far +/// coarser than the quotients' error. Should the floors still come +/// out a cent per member short, refuse (TooLargeToCompute), never +/// guess. +/// 5. unsplit takes a split back out (an entry's reversal): the running +/// wholes and exact totals are as before it, and each member's +/// running total is less exactly the part that split gave them. +/// SCENARIOS: +/// 1. IF −0.01 is split 50/50 three times: +/// - SHOULD give −0.01 / 0.00, then 0.00 / −0.01, then −0.01 / 0.00: +/// running −0.02 / −0.01 +/// BECAUSE the exact running totals are −0.005, −0.01, −0.015 each. +/// 2. IF the weights sum to zero, or the whole is zero: +/// - SHOULD give zeros, the running totals as they were. +/// 3. IF 0.01 is split 1:1, then −0.01 1:0 (running 0.00, exact +/// −0.005 / 0.005): +/// - SHOULD give 0.01 / 0.00, then −0.02 / 0.01 (running −0.01 / +/// 0.01, rounded as a loss), and the run with each whole negated +/// −0.01 / 0.00, then 0.02 / −0.01 +/// BECAUSE a loss mirrors a gain (INVARIANT 2); the member with no +/// weight moves (INVARIANT 3), which the worksheet never asks. +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunningSplit { + /// Each member's exact running total. + exact: Vec, + /// Each member's running total, to the cent. + given: Vec, + /// Σ wholes split so far. + total: Decimal, +} + +impl RunningSplit { + /// A split among `n` members, nothing split yet. + pub fn new(n: usize) -> Self { + Self { + exact: vec![Decimal::ZERO; n], + given: vec![Decimal::ZERO; n], + total: Decimal::ZERO, } } - Ok(parts) + + /// Each member's running total, to the cent (INVARIANT 2). + pub fn running(&self) -> &[Decimal] { + &self.given + } + + /// Σ weights, or None when they are all zero (nothing to split by). + fn weight_sum(&self, weights: &[Decimal]) -> Result, LedgerError> { + let n = self.given.len(); + if weights.len() != n { + return Err(LedgerError::InvalidMembers(format!( + "{} weights for {n} members", + weights.len() + ))); + } + let sum = weights + .iter() + .try_fold(Decimal::ZERO, |t, w| t.checked_add(*w)) + .ok_or_else(too_large)?; + Ok((sum > Decimal::ZERO).then_some(sum)) + } + + /// `whole × weight / sum`, the exact share of one member. + fn share(whole: Decimal, weight: Decimal, sum: Decimal) -> Result { + whole + .checked_mul(weight) + .and_then(|p| p.checked_div(sum)) + .ok_or_else(too_large) + } + + /// Split `whole` (to the cent) by `weights` (non-negative, one per + /// member): each member's part, so that their running total is the + /// rounding of their exact running total (INVARIANTS 1-4). + pub fn split( + &mut self, + whole: Decimal, + weights: &[Decimal], + ) -> Result, LedgerError> { + use rust_decimal::RoundingStrategy::ToNegativeInfinity; + let n = self.given.len(); + let Some(sum) = self.weight_sum(weights)? else { + return Ok(vec![Decimal::ZERO; n]); + }; + if whole.is_zero() { + return Ok(vec![Decimal::ZERO; n]); + } + let mut exact = self.exact.clone(); + for (x, w) in exact.iter_mut().zip(weights) { + *x = x + .checked_add(Self::share(whole, *w, sum)?) + .ok_or_else(too_large)?; + } + let total = self.total.checked_add(whole).ok_or_else(too_large)?; + // A loss is rounded as its magnitude, then negated; at a running + // whole of zero, by the sign of this whole (INVARIANT 2). + let sign = if total < Decimal::ZERO || (total.is_zero() && whole < Decimal::ZERO) { + Decimal::NEGATIVE_ONE + } else { + Decimal::ONE + }; + // Each exact running total to EXACT_PLACES (INVARIANT 4), then + // rounded down to the cent, what rounding took its remainder. + let snapped: Vec = exact + .iter() + .map(|x| (x * sign).round_dp(EXACT_PLACES)) + .collect(); + let mut parts: Vec = snapped + .iter() + .map(|x| x.round_dp_with_strategy(2, ToNegativeInfinity)) + .collect(); + let remainders: Vec = snapped.iter().zip(&parts).map(|(x, p)| x - p).collect(); + let given: Vec = self.given.iter().map(|g| g * sign).collect(); + crate::kernel::apportion_cumulative( + total * sign, + &mut parts, + &remainders, + Decimal::new(1, 2), + &given, + ) + .ok_or_else(too_large)?; + for (p, g) in parts.iter_mut().zip(self.given.iter_mut()) { + *p *= sign; + *g = g.checked_add(*p).ok_or_else(too_large)?; + } + self.exact = exact; + self.total = total; + Ok(parts) + } + + /// Take back out a split [`Self::split`] gave `parts` for, from the + /// same `whole` and `weights` (INVARIANT 5): what an entry's reversal + /// does to the running split, so the pair leaves the later splits as + /// they would be without it. + pub fn unsplit( + &mut self, + whole: Decimal, + weights: &[Decimal], + parts: &[Decimal], + ) -> Result<(), LedgerError> { + let n = self.given.len(); + let Some(sum) = self.weight_sum(weights)? else { + return Ok(()); + }; + if whole.is_zero() { + return Ok(()); + } + if parts.len() != n { + return Err(LedgerError::InvalidMembers(format!( + "{} parts for {n} members", + parts.len() + ))); + } + let mut exact = self.exact.clone(); + let mut given = self.given.clone(); + for i in 0..n { + exact[i] = exact[i] + .checked_sub(Self::share(whole, weights[i], sum)?) + .ok_or_else(too_large)?; + given[i] = given[i].checked_sub(parts[i]).ok_or_else(too_large)?; + } + self.total = self.total.checked_sub(whole).ok_or_else(too_large)?; + self.exact = exact; + self.given = given; + Ok(()) + } +} + +/// The decimal places a member's exact running total is taken to before +/// it is rounded to the cent ([`RunningSplit`] INVARIANT 4). +const EXACT_PLACES: u32 = 10; + +/// The four amounts an entry's members share (B-36): the property's +/// built-in gain, its property without a basis, its prepaid at book (each +/// by the members' property in it) and the property it distributes (by +/// what each took). +const BUILT_IN_GAIN: usize = 0; +const WITHOUT_BASIS: usize = 1; +const PREPAID: usize = 2; +const DISTRIBUTED: usize = 3; + +/// One entry's parts of the four amounts, per member; an empty list is +/// zeros. +#[derive(Clone, Debug, Default)] +struct EntryParts([Vec; 4]); + +impl EntryParts { + fn get(&self, amount: usize, member: usize) -> Decimal { + self.0[amount].get(member).copied().unwrap_or(Decimal::ZERO) + } +} + +/// One amount an entry splits: which, the whole, each member's weight. +type Whole = (usize, Decimal, Vec); + +/// What one split put into a running split, to take it back out: the +/// amount, the members with weight, the whole, their weights and parts. +type Taken = (usize, Vec, Decimal, Vec, Vec); + +/// The amounts `e` splits ([`Whole`]), from its K-1 lines. +fn entry_wholes( + ledger: &Ledger, + e: &crate::JournalEntry, + lines: &[crate::k1::K1Line], + n: usize, + cash: &[AccountId], +) -> Result, LedgerError> { + let by_member = |kind| -> Vec { + (0..n) + .map(|i| { + lines + .iter() + .filter(|l| l.member == i && l.kind == kind) + .map(|l| l.amount) + .sum() + }) + .collect() + }; + let mut out = Vec::new(); + let takes = by_member(K1Kind::PropertyContribution); + if takes.iter().any(|t| !t.is_zero()) { + let p = entry_property(ledger, e, cash)?; + out.push((BUILT_IN_GAIN, p.built_in_gain, takes.clone())); + out.push((WITHOUT_BASIS, p.without_basis, takes.clone())); + out.push((PREPAID, p.prepaid, takes)); + } + // Distributions are negative lines; weights are what each took. + let taken: Vec = by_member(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 { + out.push((DISTRIBUTED, property, taken)); + } + } + Ok(out) +} + +/// Every entry's parts of the amounts it splits (B-36 with D-38), from +/// the books' first entry through `end`, whatever span the worksheet is +/// asked for, so an entry's cents are the same in every span it is in. +/// +/// ```text +/// GOAL: odd cents that run (RunningSplit) and never touch a member who +/// has no part in the entry, nor change with the span asked for. +/// SOURCE: rules.md B-36; DECISION D-38; Form 1065 Item L (a partner's +/// capital contributed is the basis of the property that partner +/// contributed). +/// INVARIANTS: +/// 1. The entries are taken in date order, ties in posting order, +/// every posted entry through `end` but the periods' closing +/// entries, with no regard to the span: the reversed ones too. +/// 2. One running split per amount and per set of members with weight +/// in the entry (members with property in it, or who took): a split +/// never moves a member outside its set (RunningSplit INVARIANT 3 +/// never arises), and a set's count never restarts, since its +/// weights are each entry's own amounts, not the members' shares. +/// 3. An entry that reverses one already split takes that split back +/// out of its count (RunningSplit::unsplit), each member exactly +/// their part: the pair leaves every later entry's cents as they +/// would be without it. Its own parts (it reads as the contrary +/// kind, a distribution for a contribution) are split on its own, +/// largest remainder, ties to the earlier member. +/// ``` +fn entry_parts( + ledger: &Ledger, + end: NaiveDate, + members: &[MemberCapital], + cash: &[AccountId], +) -> Result, LedgerError> { + use std::collections::BTreeMap; + let n = members.len(); + let places = ledger.base_currency().allocation_places(); + let closings = ledger.closing_entry_ids(); + let mut walk: Vec<&crate::JournalEntry> = ledger + .posted_entries() + .filter(|e| e.date() <= end && !closings.contains(&e.id())) + .collect(); + walk.sort_by_key(|e| e.date()); + let mut counts: BTreeMap<(usize, Vec), RunningSplit> = BTreeMap::new(); + let mut taken_in: BTreeMap> = BTreeMap::new(); + let mut out = BTreeMap::new(); + for e in walk { + let lines = crate::k1::entry_lines(ledger, e, members, cash, places)?; + let wholes = entry_wholes(ledger, e, &lines, n, cash)?; + let mut parts = EntryParts::default(); + if let Some(back) = e.reversal_of().and_then(|o| taken_in.remove(&o)) { + for (amount, set, whole, weights, given) in back { + counts + .get_mut(&(amount, set)) + .expect("a split taken in has its count") + .unsplit(whole, &weights, &given)?; + } + for (amount, whole, weights) in wholes { + parts.0[amount] = RunningSplit::new(n).split(whole, &weights)?; + } + } else { + let mut taken = Vec::new(); + for (amount, whole, weights) in wholes { + let set: Vec = (0..n).filter(|&i| !weights[i].is_zero()).collect(); + let sub: Vec = set.iter().map(|&i| weights[i]).collect(); + let sub_parts = counts + .entry((amount, set.clone())) + .or_insert_with(|| RunningSplit::new(set.len())) + .split(whole, &sub)?; + let mut full = vec![Decimal::ZERO; n]; + for (k, &i) in set.iter().enumerate() { + full[i] = sub_parts[k]; + } + parts.0[amount] = full; + taken.push((amount, set, whole, sub, sub_parts)); + } + if !taken.is_empty() { + taken_in.insert(e.id(), taken); + } + } + out.insert(e.id(), parts); + } + Ok(out) } /// What one contribution entry's property is on the tax basis. @@ -448,6 +797,7 @@ fn span( members: &[MemberCapital], cash: &[AccountId], prior: Option<&TaxCapital>, + parts: &std::collections::BTreeMap, ) -> Result { let detail = k1_detail(ledger, start, end, members, cash)?; let n = members.len(); @@ -478,21 +828,27 @@ fn span( }) .collect() }; + let none = EntryParts::default(); + let part = parts.get(&id).unwrap_or(&none); 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)?; - let pp = share(p.prepaid, &takes)?; + // Only the members with property in the entry have a part of + // it (entry_parts INVARIANT 2). for i in (0..n).filter(|&i| !takes[i].is_zero()) { - if !pp[i].is_zero() { + let (g, w, pp) = ( + part.get(BUILT_IN_GAIN, i), + part.get(WITHOUT_BASIS, i), + part.get(PREPAID, i), + ); + if !pp.is_zero() { prop_notes[i].push(Note::PrepaidAtBook { entry: id, - book: pp[i], + book: pp, }); } - 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)?; + big[i] = big[i].checked_add(g).ok_or_else(too_large)?; + without[i] = without[i].checked_add(w).ok_or_else(too_large)?; if let Some((linked, property)) = p.mismatch { prop_open[i].push(Open::LinkedValueExceedsEntry { entry: id, @@ -500,43 +856,17 @@ fn span( property, }); } - if !w[i].is_zero() { - prop_open[i].push(Open::PropertyBasis { - entry: id, - book: w[i], - }); + if !w.is_zero() { + prop_open[i].push(Open::PropertyBasis { entry: id, book: w }); } } } - // Distributions are negative lines; weights are what each took. - let taken: Vec = 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], - }); - } + // Property distributed, by what each took. + for i in 0..n { + let book = part.get(DISTRIBUTED, i); + if !book.is_zero() { + distributed[i] = distributed[i].checked_add(book).ok_or_else(too_large)?; + dist_open[i].push(Open::PropertyDistribution { entry: id, book }); } } } @@ -681,25 +1011,131 @@ mod tests { Decimal::from_str(s).unwrap() } - /// share: proportional, to the cent, largest remainder ties to the - /// first; negative wholes negated; zero weights give zeros. + fn split(r: &mut RunningSplit, whole: &str, weights: &[&str]) -> Vec { + let w: Vec = weights.iter().map(|x| d(x)).collect(); + r.split(d(whole), &w).unwrap() + } + + /// RunningSplit: one split is proportional, to the cent, largest + /// remainder ties to the first; a negative whole mirrors a positive + /// one; zero weights give zeros; weights that don't pair are refused. #[test] - fn share_is_exact_and_signed() { + fn one_split_is_exact_and_signed() { + let mut r = RunningSplit::new(2); assert_eq!( - share(d("-1021.37"), &[d("525"), d("525")]).unwrap(), + split(&mut r, "-1021.37", &["525", "525"]), vec![d("-510.69"), d("-510.68")] ); + let mut r = RunningSplit::new(2); assert_eq!( - share(d("36"), &[d("525"), d("525")]).unwrap(), + split(&mut r, "36", &["525", "525"]), vec![d("18.00"), d("18.00")] ); + let mut r = RunningSplit::new(2); + assert_eq!(split(&mut r, "10", &["0", "0"]), vec![d("0"), d("0")]); + assert_eq!(r.running(), &[d("0"), d("0")]); + let mut r = RunningSplit::new(2); 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(), + split(&mut r, "0.01", &["1", "2"]), vec![d("0.00"), d("0.01")] ); + assert!(r.split(d("1"), &[d("1")]).is_err()); + } + + /// RunningSplit scenario 1: three odd cents 50/50 alternate, so the + /// members end one cent apart, never three. + #[test] + fn tied_odd_cents_alternate() { + let mut r = RunningSplit::new(2); + assert_eq!( + split(&mut r, "-0.01", &["1", "1"]), + vec![d("-0.01"), d("0.00")] + ); + assert_eq!( + split(&mut r, "-0.01", &["1", "1"]), + vec![d("0.00"), d("-0.01")] + ); + assert_eq!( + split(&mut r, "-0.01", &["1", "1"]), + vec![d("-0.01"), d("0.00")] + ); + assert_eq!(r.running(), &[d("-0.02"), d("-0.01")]); + } + + /// RunningSplit INVARIANT 3: a member with no weight in a split takes + /// back the cent their running total no longer earns. + #[test] + fn a_member_outside_a_split_is_trued_up() { + let mut r = RunningSplit::new(3); + // A and B 50/50 of 0.01: exact 0.005 each, the cent to A. + assert_eq!( + split(&mut r, "0.01", &["1", "1", "0"]), + vec![d("0.01"), d("0"), d("0")] + ); + // B and C 1:3 of 0.02: exact 0.005 / 0.01 / 0.015 of 0.03; A and + // C tie on remainders, the odd cent stays with A. + assert_eq!( + split(&mut r, "0.02", &["0", "1", "3"]), + vec![d("0"), d("0.01"), d("0.01")] + ); + // B and C 2:1 of 0.01: exact 0.005 / 0.01667 / 0.01833 of 0.04; + // C's and B's remainders now beat A's, so A gives the cent back. + assert_eq!( + split(&mut r, "0.01", &["0", "2", "1"]), + vec![d("-0.01"), d("0.01"), d("0.01")] + ); + assert_eq!(r.running(), &[d("0"), d("0.02"), d("0.02")]); + } + + /// RunningSplit scenario 3, INVARIANT 2: at a running whole of zero + /// the rounding takes the sign of the whole just split, so a run of + /// losses mirrors its run of gains part for part (P4 review: rounded + /// as a gain at zero, the negated run ended 0.01 / −0.01, moving the + /// member with no weight). + #[test] + fn a_loss_mirrors_a_gain_at_zero() { + let mut gain = RunningSplit::new(2); + let mut loss = RunningSplit::new(2); + for (whole, weights, want) in [ + ("0.01", ["1", "1"], ["0.01", "0"]), + ("-0.01", ["1", "0"], ["-0.02", "0.01"]), + ] { + let g = split(&mut gain, whole, &weights); + let l = split(&mut loss, &format!("{}", -d(whole)), &weights); + assert_eq!(g, vec![d(want[0]), d(want[1])]); + assert_eq!(l, g.iter().map(|x| -x).collect::>()); + } + assert_eq!(gain.running(), &[d("-0.01"), d("0.01")]); + assert_eq!(loss.running(), &[d("0.01"), d("-0.01")]); + } + + /// RunningSplit INVARIANT 5: a split taken back out leaves the next + /// split as if it never was. + #[test] + fn unsplit_takes_a_split_back_out() { + let mut r = RunningSplit::new(2); + let parts = split(&mut r, "-12.37", &["1", "1"]); + assert_eq!(parts, vec![d("-6.19"), d("-6.18")]); + r.unsplit(d("-12.37"), &[d("1"), d("1")], &parts).unwrap(); + assert_eq!(r, RunningSplit::new(2)); + assert_eq!( + split(&mut r, "-55.91", &["1", "1"]), + vec![d("-27.96"), d("-27.95")] + ); + assert!(r.unsplit(d("1"), &[d("1")], &parts).is_err()); + assert!(r.unsplit(d("1"), &[d("1"), d("1")], &[d("1")]).is_err()); + } + + /// RunningSplit INVARIANT 4: thirds summed in Decimal come out a hair + /// under the cents they are (399.93 and 791.11); taken to ten places + /// they are those cents, and the split goes through. + #[test] + fn thirds_summed_are_the_cents_they_are() { + let mut r = RunningSplit::new(2); + split(&mut r, "283.43", &["4", "2"]); + split(&mut r, "274.68", &["0", "1"]); + split(&mut r, "824.32", &["2", "4"]); + split(&mut r, "-191.39", &["1", "2"]); + assert_eq!(r.running(), &[d("399.93"), d("791.11")]); } } diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index e4e355d..ca9087d 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -3281,8 +3281,8 @@ fn every_bookspec_example_holds() { ("register_row", 5), ("register_totals", 1), ("asset_basis", 6), - ("k1_tax_basis", 2), - ("k1_reconciliation", 1), + ("k1_tax_basis", 4), + ("k1_reconciliation", 2), ("project", 6), ] { let n = checked.get(q).copied().unwrap_or(0); diff --git a/crates/ledger-core/tests/tax_capital.proptest-regressions b/crates/ledger-core/tests/tax_capital.proptest-regressions new file mode 100644 index 0000000..45fdd12 --- /dev/null +++ b/crates/ledger-core/tests/tax_capital.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 56101dd675399c931df47369972f415782f4cd0e6ea874c37ef655b9961b69cb # shrinks to n = 2, splits = [(28343, [4, 2, 0, 0]), (27468, [0, 1, 0, 0]), (82432, [2, 4, 0, 0]), (-19139, [1, 2, 0, 0])] diff --git a/crates/ledger-core/tests/tax_capital.rs b/crates/ledger-core/tests/tax_capital.rs index 7d19fc5..b045549 100644 --- a/crates/ledger-core/tests/tax_capital.rs +++ b/crates/ledger-core/tests/tax_capital.rs @@ -14,11 +14,12 @@ use std::str::FromStr; use chrono::NaiveDate; use ledger_core::reports::MemberCapital; -use ledger_core::tax_capital::{Note, Open, Reconciliation, tax_basis_capital}; +use ledger_core::tax_capital::{Note, Open, Reconciliation, RunningSplit, tax_basis_capital}; use ledger_core::{ AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money, NewAsset, NewSchedule, ScheduleConvention, ScheduleKind, Sec704cMethod, Side, }; +use proptest::prelude::*; use rust_decimal::Decimal; fn d(s: &str) -> Decimal { @@ -97,10 +98,22 @@ fn members(b: &Books) -> [MemberCapital; 2] { /// A device two members contribute, in service 2026-01-01 over 24 months. fn device(b: &mut Books, name: &str, fmv: &str, adjusted: &str, entry: EntryId) -> AssetId { + device_on(b, name, fmv, adjusted, entry, date(2026, 1, 1)) +} + +/// A device two members contribute, in service `on` over 24 months. +fn device_on( + b: &mut Books, + name: &str, + fmv: &str, + adjusted: &str, + entry: EntryId, + on: NaiveDate, +) -> AssetId { let id = b.l.register_asset(NewAsset { description: name.into(), - in_service_date: date(2026, 1, 1), + in_service_date: on, cost: usd(fmv), salvage_value: usd("0.00"), useful_life_months: 24, @@ -734,3 +747,395 @@ fn unequal_contributors_and_a_member_with_none() { assert_eq!(none.ending.known, d("0")); assert_eq!(none.reconciliation, Reconciliation::default()); } + +/// The odd cent on the tax basis runs (B-36 with D-38; owner, 2026-10-22): +/// three devices contributed 50/50, each worth less than its basis by an +/// odd number of cents, the built-in gain shared cumulatively, so the +/// members' tax property ends one cent apart, never three. By hand: +/// - 2026-01-01 a monitor, 400.00 (basis 412.37, gain −12.37): exact +/// −6.185 each, the tied cent to Tobin: −6.19 / −6.18; +/// - 2026-06-01 a chair, 250.00 (basis 263.41, gain −13.41): running +/// −25.78, exact −12.89 each: −6.70 / −6.71; +/// - 2026-11-01 a desk, 600.00 (basis 655.91, gain −55.91): running +/// −81.69, exact −40.845 each, the tied cent to Tobin: −27.96 / −27.95. +/// +/// Running built-in gain −40.85 / −40.84; book property 625.00 each, tax +/// 665.85 / 665.84. Shared entry by entry, Tobin took all three tied +/// cents: 665.86 / 665.83. From June: book 425.00 each; gain −34.66 each +/// (−6.70 − 27.96, −6.71 − 27.95), tax 459.66 each, on a beginning of +/// 206.19 / 206.18, ending at the year's figures. +#[test] +fn tied_odd_cents_of_property_basis_run_cumulatively() { + let mut b = books(); + let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); + for (on, name, fmv, adjusted, half) in [ + (date(2026, 1, 1), "Monitor", "400.00", "412.37", "200.00"), + (date(2026, 6, 1), "Chair", "250.00", "263.41", "125.00"), + (date(2026, 11, 1), "Desk", "600.00", "655.91", "300.00"), + ] { + let e = post( + &mut b.l, + on, + &[ + (equipment, Side::Debit, fmv), + (tobin, Side::Credit, half), + (morgan, Side::Credit, half), + ], + ); + device_on(&mut b, name, fmv, adjusted, e, on); + } + let ms = members(&b); + let year = tax_basis_capital(&b.l, date(2026, 1, 1), date(2026, 12, 31), &ms, &[cash]).unwrap(); + let from_june = + tax_basis_capital(&b.l, date(2026, 6, 1), date(2026, 12, 31), &ms, &[cash]).unwrap(); + for (i, (tax, big, beginning)) in [ + ("665.85", "-40.85", "206.19"), + ("665.84", "-40.84", "206.18"), + ] + .into_iter() + .enumerate() + { + let row = &year.rows[i]; + assert_eq!(row.book.contributions_property, d("625.00")); + assert_eq!(row.contributions_property.tax(), Some(d(tax))); + assert_eq!(row.reconciliation.built_in_gain_704c, d(big)); + assert_eq!(row.ending.known, d(tax)); + let june = &from_june.rows[i]; + assert_eq!(june.beginning.known, d(beginning)); + assert_eq!(june.book.contributions_property, d("425.00")); + assert_eq!(june.contributions_property.tax(), Some(d("459.66"))); + assert_eq!(june.reconciliation.built_in_gain_704c, d("-34.66")); + assert_eq!(june.ending.known, row.ending.known, "the spans chain"); + } +} + +/// A third member, Avery Quinn, beside Tobin and Morgan (made up), with +/// shares 0.5 / 0.3 / 0.2. +fn three_members(b: &mut Books) -> (AccountId, [MemberCapital; 3]) { + let third = + b.l.add_account( + "3002", + "Capital, Avery Quinn", + AccountType::Equity, + None, + false, + ) + .unwrap(); + let [t, m] = members(b); + ( + third, + [ + 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"), + }, + ], + ) +} + +/// A member never takes a cent of an entry they had no part in (B-36, +/// Form 1065 Item L: capital contributed is the basis of the property +/// that member contributed; P4 review, 2026-10-22). Three members; by +/// hand, each set of contributing members its own running split: +/// - 2026-01-01 Tobin and Morgan, 50.00 each of a 100.00 device (basis +/// 100.01, gain −0.01): −0.01 / 0.00, the tied cent to Tobin; +/// - 2026-02-01 Morgan and Avery, 25.00 / 75.00 of a 100.00 device +/// (basis 100.02, gain −0.02): exact −0.005 / −0.015, the tied cent to +/// Morgan: −0.01 / −0.01; +/// - 2027-01-01 Morgan and Avery, 60.00 / 30.00 of a 90.00 device (basis +/// 90.01, gain −0.01): Morgan and Avery's count runs −0.03, exact +/// −0.011667 / −0.018333, rounded −0.01 / −0.02: 0.00 / −0.01. +/// +/// 2027: Tobin nothing at all; Morgan 60.00 at tax, Avery 30.01, together +/// the device's §723 basis 90.01. One running split for all three +/// members instead (the first cut) gives Tobin, who put in nothing in +/// 2027, −0.01 of tax property and +0.01 of built-in gain, Morgan and +/// Avery 60.01 / 30.01, more than the basis. +#[test] +fn a_member_outside_an_entry_never_takes_its_cents() { + let mut b = books(); + let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); + let (avery, ms) = three_members(&mut b); + for (on, value, basis, credits) in [ + ( + date(2026, 1, 1), + "100.00", + "100.01", + [(tobin, "50.00"), (morgan, "50.00")], + ), + ( + date(2026, 2, 1), + "100.00", + "100.02", + [(morgan, "25.00"), (avery, "75.00")], + ), + ( + date(2027, 1, 1), + "90.00", + "90.01", + [(morgan, "60.00"), (avery, "30.00")], + ), + ] { + let e = post( + &mut b.l, + on, + &[ + (equipment, Side::Debit, value), + (credits[0].0, Side::Credit, credits[0].1), + (credits[1].0, Side::Credit, credits[1].1), + ], + ); + device_on(&mut b, "Device", value, basis, e, on); + } + let year = |y| tax_basis_capital(&b.l, date(y, 1, 1), date(y, 12, 31), &ms, &[cash]).unwrap(); + let (y26, y27) = (year(2026), year(2027)); + // Each year's built-in shares sum to its entries' gains, and the tax + // property to their bases. + for (tc, gain, basis) in [(&y26, "-0.03", "200.03"), (&y27, "-0.01", "90.01")] { + let big: Decimal = tc + .rows + .iter() + .map(|r| r.reconciliation.built_in_gain_704c) + .sum(); + assert_eq!(big, d(gain)); + let tax: Decimal = tc + .rows + .iter() + .map(|r| r.contributions_property.tax().unwrap()) + .sum(); + assert_eq!(tax, d(basis)); + } + for (row, (book, tax, big)) in y26.rows.iter().zip([ + ("50.00", "50.01", "-0.01"), + ("75.00", "75.01", "-0.01"), + ("75.00", "75.01", "-0.01"), + ]) { + assert_eq!( + row.book.contributions_property, + d(book), + "{}", + row.book.name + ); + assert_eq!(row.contributions_property.tax(), Some(d(tax))); + assert_eq!(row.reconciliation.built_in_gain_704c, d(big)); + } + for (row, (book, tax, big)) in y27.rows.iter().zip([ + ("0", "0", "0"), + ("60.00", "60.00", "0"), + ("30.00", "30.01", "-0.01"), + ]) { + assert_eq!( + row.book.contributions_property, + d(book), + "{}", + row.book.name + ); + assert_eq!(row.contributions_property.tax(), Some(d(tax))); + assert_eq!(row.reconciliation.built_in_gain_704c, d(big)); + } + let t = &y27.rows[0].contributions_property; + assert!(t.open.is_empty() && t.notes.is_empty(), "{t:?}"); +} + +/// An entry's odd cent is the same whatever span the worksheet is asked +/// for (B-36; P4 review, 2026-10-22). Tobin and Morgan, 50/50: a monitor +/// contributed 2026-01-01 (400.00, basis 412.37, gain −12.37), reversed +/// 2026-07-01; a desk contributed 2026-11-01 (600.00, basis 655.91, gain +/// −55.91). The reversal takes the monitor's split back out of the count, +/// so the desk is split as if the monitor never was: exact −27.955 each, +/// the tied cent to Tobin, −27.96 / −27.95, in every span. The first cut +/// carried the count span by span: from June the monitor was split in the +/// span before and its reversal read as a distribution, so the desk's +/// cent went to Morgan there and to Tobin in the others. +#[test] +fn an_entrys_odd_cent_is_the_same_in_every_span() { + let mut b = books(); + let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); + let mut entries = Vec::new(); + for (on, name, fmv, adjusted, half) in [ + (date(2026, 1, 1), "Monitor", "400.00", "412.37", "200.00"), + (date(2026, 11, 1), "Desk", "600.00", "655.91", "300.00"), + ] { + let e = post( + &mut b.l, + on, + &[ + (equipment, Side::Debit, fmv), + (tobin, Side::Credit, half), + (morgan, Side::Credit, half), + ], + ); + device_on(&mut b, name, fmv, adjusted, e, on); + entries.push(e); + } + b.l.reverse_entry(entries[0], date(2026, 7, 1), "monitor back") + .unwrap(); + let ms = members(&b); + for start in [ + date(2026, 1, 1), + date(2026, 6, 1), + date(2026, 8, 1), + date(2026, 10, 1), + ] { + let tc = tax_basis_capital(&b.l, start, date(2026, 12, 31), &ms, &[cash]).unwrap(); + let big: Vec = tc + .rows + .iter() + .map(|r| r.reconciliation.built_in_gain_704c) + .collect(); + assert_eq!(big, vec![d("-27.96"), d("-27.95")], "from {start}"); + } +} + +/// The running split takes the entries in date order, whatever order +/// they were posted in, so next year's beginning is this year's ending +/// to the cent (B-36). Tobin and Morgan, 50/50: a chair dated 2027-03-01 +/// (250.00, basis 263.41) posted first, then a monitor dated 2026-01-01 +/// (400.00, basis 412.37). The monitor comes first: −6.19 / −6.18, then +/// the chair −6.70 / −6.71. Taken in posting order the 2027 worksheet +/// would split the chair first and the monitor −6.18 / −6.19 in its +/// beginning, a cent from the 2026 worksheet's ending. +#[test] +fn the_split_runs_in_date_order() { + let mut b = books(); + let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); + for (on, name, fmv, adjusted, half) in [ + (date(2027, 3, 1), "Chair", "250.00", "263.41", "125.00"), + (date(2026, 1, 1), "Monitor", "400.00", "412.37", "200.00"), + ] { + let e = post( + &mut b.l, + on, + &[ + (equipment, Side::Debit, fmv), + (tobin, Side::Credit, half), + (morgan, Side::Credit, half), + ], + ); + device_on(&mut b, name, fmv, adjusted, e, on); + } + let ms = members(&b); + let year = |y| tax_basis_capital(&b.l, date(y, 1, 1), date(y, 12, 31), &ms, &[cash]).unwrap(); + let (y26, y27) = (year(2026), year(2027)); + for (i, (big26, big27)) in [("-6.19", "-6.70"), ("-6.18", "-6.71")] + .into_iter() + .enumerate() + { + assert_eq!(y26.rows[i].reconciliation.built_in_gain_704c, d(big26)); + assert_eq!(y27.rows[i].reconciliation.built_in_gain_704c, d(big27)); + assert_eq!(y27.rows[i].beginning.known, y26.rows[i].ending.known); + } +} + +/// lcm(1..=16): every sum of up to four weights of 0..=4 divides it. It +/// is under 10^8, so two exact running totals that differ at all differ +/// by more than RunningSplit's ten places can blur (INVARIANT 4). +const LCM: i128 = 720_720; + +/// The largest-remainder rounding to the cent of exact running totals +/// `exact` (in cents × LCM) summing to `total` cents, ties to the earlier +/// member, a negative total (or a zero one after a negative whole, +/// `negative`) rounded as its magnitude and negated: RunningSplit +/// INVARIANT 2 in integers. +fn largest_remainder(exact: &[i128], total: i128, negative: bool) -> Vec { + let sign = if total < 0 || (total == 0 && negative) { + -1 + } else { + 1 + }; + let floors: Vec = exact.iter().map(|x| (sign * x).div_euclid(LCM)).collect(); + let rems: Vec = exact.iter().map(|x| (sign * x).rem_euclid(LCM)).collect(); + let short = sign * total - floors.iter().sum::(); + assert!((0..=exact.len() as i128).contains(&short)); + let rank = |i: usize| { + (0..rems.len()) + .filter(|&j| rems[j] > rems[i] || (rems[j] == rems[i] && j < i)) + .count() as i128 + }; + (0..exact.len()) + .map(|i| sign * (floors[i] + i128::from(rank(i) < short))) + .collect() +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(5000))] + + /// Law: RunningSplit INVARIANTS 1 and 2 (B-36 with D-38): for any run + /// of splits, wholes of either sign to the cent (a third of them a few + /// cents, so the running whole often comes back to zero) and each its own + /// weights among up to four members (small ratios, scaled by a cent + /// amount up to ten million, so the quotients are a cent amount's), + /// each split's parts sum to its whole, the running totals sum to + /// everything split, every member's running total is the + /// largest-remainder rounding of the exact running totals, ties to the + /// earlier member (so never a cent from exact), checked in exact + /// integers, and the run with every whole negated gives every part + /// negated. + #[test] + fn a_running_split_never_drifts_a_cent( + n in 1usize..=4, + splits in prop::collection::vec( + ( + prop_oneof![-3i64..=3, -100_000i64..=100_000, -1_000_000_000_000i64..=1_000_000_000_000], + prop::collection::vec(0u8..=4, 4), + prop_oneof![Just(1i64), 1i64..=1_000_000_000], + ), + 1..24, + ), + ) { + let mut r = RunningSplit::new(n); + let mut mirror = RunningSplit::new(n); + let mut exact = vec![0i128; n]; + let mut total = 0i128; + for (whole, weights, scale) in &splits { + let w = &weights[..n]; + let sum: i128 = w.iter().map(|&x| i128::from(x)).sum(); + let ws: Vec = w + .iter() + .map(|&x| Decimal::new(i64::from(x) * scale, 2)) + .collect(); + let parts = r.split(Decimal::new(*whole, 2), &ws).unwrap(); + let negated = mirror.split(Decimal::new(-*whole, 2), &ws).unwrap(); + prop_assert_eq!(parts.len(), n); + prop_assert_eq!( + negated, + parts.iter().map(|p| -p).collect::>(), + "a loss mirrors a gain" + ); + let split: Decimal = parts.iter().sum(); + if sum == 0 || *whole == 0 { + prop_assert_eq!(split, Decimal::ZERO); + continue; + } + prop_assert_eq!(split, Decimal::new(*whole, 2)); + total += i128::from(*whole); + for (x, &wi) in exact.iter_mut().zip(w) { + *x += i128::from(*whole) * i128::from(wi) * (LCM / sum); + } + let running: Decimal = r.running().iter().sum(); + prop_assert_eq!(running, Decimal::from_i128_with_scale(total, 2)); + let want = largest_remainder(&exact, total, *whole < 0); + for (i, (&running, &exact)) in r.running().iter().zip(&exact).enumerate() { + let cents = (running * Decimal::ONE_HUNDRED).normalize(); + prop_assert_eq!(cents.scale(), 0, "a running total to the cent"); + let cents = i128::try_from(cents).unwrap(); + prop_assert_eq!(cents, want[i], "member {}: the largest-remainder rounding", i); + prop_assert!( + (cents * LCM - exact).abs() < LCM, + "member {} at {} against exact {}/{}", + i, cents, exact, LCM + ); + } + } + } +} diff --git a/crates/ledger-mcp/handbook/tracing-figures.md b/crates/ledger-mcp/handbook/tracing-figures.md index fa757c8..4b01bcb 100644 --- a/crates/ledger-mcp/handbook/tracing-figures.md +++ b/crates/ledger-mcp/handbook/tracing-figures.md @@ -6,3 +6,4 @@ - Never guess, and never look for the books' files: the tools are the evidence. - books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact. - A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count. +- A member's odd cent on the tax-basis K-1 (books.export_cpa_package's k1-tax-basis-capital.csv): each contribution's built-in gain, property without a basis and prepaid, and each distribution's property, are split among the members with a part in that entry (by their property in it, or what each took) the same running way, one count for each set of such members, from the books' first entry in date order whatever the span. So an entry can give a member a cent more or less than its own split would, because of an earlier entry by the same members; never a cent to a member with no part in it. A reversal takes its original's cents back out, so an entry's cents are the same in every span. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 9736170..939a5e5 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -2606,7 +2606,36 @@ book capital reconciles to tax capital: - Property contributed enters at its §723 basis (B-25): the member's share of the property's book value less their share of its built-in 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 + is (B-13). To the cent, these shares (and those of property without a + basis, of a prepaid at book, and of property distributed) run + cumulatively, D-38's rule (the owner, 2026-10-22): each member's + running share is the largest-remainder rounding of their exact running + share (ties to the member listed first), less what earlier entries + gave them, so it is never a cent from exact, and tied odd cents + alternate rather than all going to one member (three equal odd-cent + properties at 50/50 leave the members a cent apart, never three). How + the count runs differs from the closes': a close splits by the + members' shares among all the members, so its count starts afresh when + members or shares change; here each entry splits by its own amounts + (each member's property in it, or what each took) among the members + with a part in it. So there is one count for each set of members with + a part in an entry, and it never restarts: shares don't enter it, and + a member outside the set is never moved by it (Item L's capital + contributed is the basis of the property that member contributed, so + a member who contributed nothing in an entry takes none of its + cents). The count takes every posted entry from the books' first in + date order (ties in posting order), whatever span the worksheet is + for, and an entry's reversal takes the original's split back out of + the count, each member exactly their part: an entry's cents are the + same in every span it is in, and next year's beginning is this year's + ending to the cent. The book property each member contributed in an + entry (B-13's share of the entry's property among the members' + credits) stays split entry by entry, ties to the member listed first: + each member's property there must stay within their own credit in the + entry, which a running true-up could break; its odd cents arise only + in an entry part cash and part property whose credits don't divide + the property evenly. + Property contributed with no basis in the books (no registered asset linked to the entry holds a contribution basis for it) is open. Property contributed to a prepaid account (the balance account of a prepaid-expense schedule: a service contract the member @@ -2638,7 +2667,14 @@ book capital reconciles to tax capital: **Enforced:** `ledger_core::tax_capital::tax_basis_capital`; `ledger-package` `k1_tax` (`k1-tax-basis-capital.csv` in the CPA -package). **Checked:** `ledger-core` `tests/tax_capital.rs`; +package); the odd cents, `ledger_core::tax_capital::RunningSplit` on +`kernel::apportion_cumulative`. **Checked:** `ledger-core` +`tests/tax_capital.rs` (`tied_odd_cents_of_property_basis_run_cumulatively`, +`a_member_outside_an_entry_never_takes_its_cents`, +`an_entrys_odd_cent_is_the_same_in_every_span`, +`the_split_runs_in_date_order`, the law +`a_running_split_never_drifts_a_cent`), the unit tests in +`src/tax_capital.rs`, `bookspec/examples/partnership-k1-tax-basis-odd-cents.toml`; `ledger-package` `tests/package.rs` (`the_k1_worksheet_reports_tax_basis_capital`), `tests/p3_seams.rs` (`depreciation_is_one_figure_everywhere`, diff --git a/docs/testing.md b/docs/testing.md index 5944984..dc28e6f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1611,7 +1611,40 @@ basis. Mutation runs wait for P12 (D-33); none was run. 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); + members with unequal shares, one contributing nothing; and, from the + owner's ask of 2026-10-22 (B-36 with D-38), three odd-cent devices + contributed 50/50 whose built-in loss runs cumulatively, a cent apart + for the year and from June on a beginning carried from January + (`tied_odd_cents_of_property_basis_run_cumulatively`; it fails with + the per-entry split, 665.86 / 665.83), and the law + `a_running_split_never_drifts_a_cent` (5,000 cases, up to four + members, two dozen splits each with its own weights, small ratios + scaled by a cent amount up to ten million, wholes to ten billion + either sign, a third of them a few cents so the running whole often + returns to zero: each split sums to its whole, the running totals to + everything split, each member's running total is the + largest-remainder rounding of the exact running totals, ties to the + first, so never a cent from exact, checked in integers, and the run + with every whole negated gives every part negated; it found the + Decimal thirds case `RunningSplit` now takes to ten places, kept as a + regression and as the unit test `thirds_summed_are_the_cents_they_are`; + with ties given to the later member it fails on the rounding, and + with a zero running whole rounded as a gain it fails on the mirror); + from the P4 review (2026-10-22), three worksheet tests: + `a_member_outside_an_entry_never_takes_its_cents` (three members, a + later entry by two of them: the third's line is nothing at all, and + each year's built-in shares and tax property sum to its entries'; it + fails with one count for all members, the first cut, which gave the + third −0.01 of tax property), `an_entrys_odd_cent_is_the_same_in_every_span` + (a reversed contribution across the span's start: the desk's cent is + the same from January, June, August and October; it fails with the + count carried span by span, and with the reversal not taken back + out), and `the_split_runs_in_date_order` (a 2027 entry posted before a + 2026 one: next year's beginning is this year's ending; it fails with + the count in posting order); the unit tests in `src/tax_capital.rs` + (one split, tied cents alternating, a member outside a split trued + up, a loss mirroring a gain at a running whole of zero, a split taken + back out); `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 @@ -1670,7 +1703,10 @@ reversal test; `check_depreciation_run`'s arms; the register filter `check_journaled` and `check_legacy_journaled` arms; the acquisition and in-service date refusals; the disposal floor; the MCP `aliased` logic (both given and differing); `asset_basis_from_journal` (newer vs corrupt); -the projection loader's two key sets; `tax_capital::share` and +the projection loader's two key sets; `tax_capital::RunningSplit` (the +sign, its zero case, the snap to ten places, `unsplit`), +`tax_capital::entry_parts` (the member set, the date order, the +reversal's take-back) and `entry_property` (the mismatch branch, the unknown sum), the open-item conditions (`in_service`, the disposal window), and the reconciliation's signs. diff --git a/docs/verification.md b/docs/verification.md index 3ff8029..734d687 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -267,6 +267,20 @@ The P3 odd cent (defect 17; rules.md B-13; D-10, D-38) adds three laws: `Apportion.allocW_cumulative_drift` (the two together): any number of members, any amounts - property `members_capital_never_drifts_a_cent_from_their_share` + - the tax-basis K-1's running split (B-36 with D-38, 2026-10-22), + whose splits each carry their own weights, so the exact running + share is a sum of quotients and Decimal's division can leave it a + hair under the cent it is (`tax_capital::RunningSplit` takes it to + ten places first): property `a_running_split_never_drifts_a_cent` + (`ledger-core/tests/tax_capital.rs`, checked against the + largest-remainder rounding of exact integer shares, and a loss + against its gain); its kernel step is `apportion_cumulative`, + proved above. The worksheet keeps one per set of members with a + part in an entry, in date order whatever the span + (`tax_capital::entry_parts`; tests + `a_member_outside_an_entry_never_takes_its_cents`, + `an_entrys_odd_cent_is_the_same_in_every_span`, + `the_split_runs_in_date_order`) 22. **A member's capital on a day never moves when its period is closed.** The statement of members' equity gives each member, of the earnings not yet closed, what the count's next close would give them; closing moves diff --git a/plan/STATUS.md b/plan/STATUS.md index 3e13376..cdd65d0 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -189,9 +189,25 @@ its invoices. The handbook's caveat is gone for books with the feature. (`wanderbooks books upgrade`, D-39) their invoices and bills still take none (the handbook says so). - Raised by the cutover review (private change log, "Open points"): - ties in the per-entry member split of the tax-basis K-1 all go to the - first member (the owner, 2026-10-22: nothing is posted until the - year-end close, three weeks off, so it waits). + its three points are done. The third (2026-10-22, B-36 with D-38): the + tax-basis K-1's odd cents of property basis no longer all go to the + first member; they run cumulatively, D-38's largest remainder, one + count per set of members with a part in an entry, in date order + whatever the span, a reversal taking its original back out + (`tax_capital::RunningSplit`, `entry_parts`; tests + `tied_odd_cents_of_property_basis_run_cumulatively`, + `a_member_outside_an_entry_never_takes_its_cents`, + `an_entrys_odd_cent_is_the_same_in_every_span`, + `the_split_runs_in_date_order`, the law + `a_running_split_never_drifts_a_cent`; bookspec + `partnership-k1-tax-basis-odd-cents`). Left as it was, and said in + B-36: the book property each member contributed in an entry is still + split entry by entry, ties to the first member (each member's property + must stay within their own credit in the entry); its odd cents arise + only in an entry part cash and part property whose credits don't + divide the property evenly. Whether the first company's three-cent + gap between its members came wholly from the basis split is to be + checked on its books when the K-1 is next run, at the year-end close. Its second point is done (2026-10-22, B-14 rule 5): a payment to a government (ASC 230-10-45-17(c), "Cash payments to governments for taxes, duties, fines, and other fees or penalties") is other operating