//! Spec tests for the K-1 worksheet's tax-basis capital //! (`ledger_core::tax_capital`, rules.md B-36): Item L on the tax basis //! beside the book figures, every figure the books can't know left open //! and named, and the reconciliation from book to tax capital. //! //! Expected values are worked by hand from the Form 1065 instructions' //! Item L (`docs/spec/sources/irs-partnerships.md`): capital contributed is //! "the amount of cash plus the adjusted tax basis of all property //! contributed"; withdrawals and distributions "the amount of cash plus //! the adjusted tax basis of all property distributed". Names and figures //! are invented. use std::str::FromStr; use chrono::NaiveDate; use ledger_core::reports::MemberCapital; use ledger_core::tax_capital::{Note, Open, Reconciliation, tax_basis_capital}; use ledger_core::{ AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money, NewAsset, NewSchedule, ScheduleConvention, ScheduleKind, Sec704cMethod, Side, }; use rust_decimal::Decimal; fn d(s: &str) -> Decimal { Decimal::from_str(s).unwrap() } fn date(y: i32, m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, day).unwrap() } fn usd(s: &str) -> Money { Money::new(d(s), Currency::USD) } struct Books { l: Ledger, cash: AccountId, prepaid: AccountId, equipment: AccountId, accum: AccountId, tobin: AccountId, morgan: AccountId, revenue: AccountId, depreciation: AccountId, } fn books() -> Books { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); let cash = add("1000", "Bank", AccountType::Asset); let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset); let equipment = add("1500", "Equipment", AccountType::Asset); let accum = add("1510", "Accumulated Depreciation", AccountType::Asset); let tobin = add("3000", "Capital, Tobin Harlow", AccountType::Equity); let morgan = add("3001", "Capital, Morgan Ellis", AccountType::Equity); let revenue = add("4000", "Revenue", AccountType::Revenue); let depreciation = add("6200", "Depreciation", AccountType::Expense); Books { l, cash, prepaid, equipment, accum, tobin, morgan, revenue, depreciation, } } fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId { let e = l.try_create_entry(on, "entry").unwrap(); for (a, side, amt) in lines { l.add_line(e, *a, *side, usd(amt)).unwrap(); } l.post_entry(e).unwrap(); e } fn members(b: &Books) -> [MemberCapital; 2] { [ MemberCapital { name: "Tobin Harlow".into(), capital_account: b.tobin, distribution_account: None, share: d("0.5"), }, MemberCapital { name: "Morgan Ellis".into(), capital_account: b.morgan, distribution_account: None, share: d("0.5"), }, ] } /// A device two members contribute, in service 2026-01-01 over 24 months. fn device(b: &mut Books, name: &str, fmv: &str, adjusted: &str, entry: EntryId) -> AssetId { let id = b.l.register_asset(NewAsset { description: name.into(), in_service_date: date(2026, 1, 1), cost: usd(fmv), salvage_value: usd("0.00"), useful_life_months: 24, cost_account: b.equipment, accum_depr_account: b.accum, expense_account: b.depreciation, }) .unwrap(); b.l.set_asset_acquisition(id, entry).unwrap(); let mut basis = AssetBasis::contribution(BasisRule::PersonalUseConversion, d(adjusted), d(fmv)); basis.sec704c_method = Some(Sec704cMethod::Traditional); b.l.set_asset_basis(id, basis).unwrap(); id } /// Every month's run Jan..=Dec 2026, as the schedule gives it. fn a_year_of_runs(b: &mut Books, id: AssetId) { let a = b.l.get_asset(id).unwrap().clone(); for l in ledger_core::depreciation_schedule(&a, date(2026, 12, 31)).unwrap() { let (y, m) = ( l.period[..4].parse().unwrap(), l.period[5..].parse().unwrap(), ); let end = date(y, m, 1) .checked_add_months(chrono::Months::new(1)) .unwrap() .pred_opt() .unwrap(); let amt = l.amount.to_string(); let e = post( &mut b.l, end, &[ (b.depreciation, Side::Debit, &amt), (b.accum, Side::Credit, &amt), ], ); b.l.check_depreciation_run(id, &l.period, e, l.amount) .unwrap(); b.l.record_depreciation_run(id, &l.period, e, l.amount) .unwrap(); } } /// The year, by hand. Tobin Harlow and Morgan Ellis, 50/50: /// - 2026-01-01 cash in: Dr 1000 2,000.00 / Cr 3000 1,000.00, Cr 3001 /// 1,000.00; /// - 2026-01-01 two devices in, jointly, from personal use: a laptop worth /// 1,440.00 (adjusted basis 1,688.40: built-in loss 248.40) and a camera /// worth 1,300.00 (basis 1,080.00: built-in gain 220.00); Dr 1500 /// 2,740.00 / Cr 3000 1,370.00, Cr 3001 1,370.00; /// - revenue 600.00; a year of depreciation, 24-month lives: 1,440/2 + /// 1,300/2 = 720.00 + 650.00 = 1,370.00; net income 600.00 − 1,370.00 = /// −770.00, −385.00 each; /// - 2026-12-15 cash out: Dr 3000 300.00, Dr 3001 300.00 / Cr 1000 600.00. /// /// Book: 1,000.00 + 1,370.00 − 385.00 − 300.00 = 1,685.00 each. /// Tax: property at §723 basis, (1,688.40 + 1,080.00) / 2 = 1,384.20 each /// (book 1,370.00 less the built-in gain share (−248.40 + 220.00) / 2 = /// −14.20); the year's income is open (tax depreciation is the CPA's), so /// the ending the books know is 1,000.00 + 1,384.20 − 385.00 − 300.00 = /// 1,699.20; book less tax = −14.20, the built-in gain share. fn the_year() -> (Books, EntryId, AssetId, AssetId) { let mut b = books(); let (cash, tobin, morgan, equipment) = (b.cash, b.tobin, b.morgan, b.equipment); post( &mut b.l, date(2026, 1, 1), &[ (cash, Side::Debit, "2000.00"), (tobin, Side::Credit, "1000.00"), (morgan, Side::Credit, "1000.00"), ], ); let devices = post( &mut b.l, date(2026, 1, 1), &[ (equipment, Side::Debit, "1440.00"), (equipment, Side::Debit, "1300.00"), (tobin, Side::Credit, "1370.00"), (morgan, Side::Credit, "1370.00"), ], ); let laptop = device(&mut b, "Laptop", "1440.00", "1688.40", devices); let camera = device(&mut b, "Camera", "1300.00", "1080.00", devices); let revenue = b.revenue; post( &mut b.l, date(2026, 2, 1), &[ (cash, Side::Debit, "600.00"), (revenue, Side::Credit, "600.00"), ], ); a_year_of_runs(&mut b, laptop); a_year_of_runs(&mut b, camera); post( &mut b.l, date(2026, 12, 15), &[ (tobin, Side::Debit, "300.00"), (morgan, Side::Debit, "300.00"), (cash, Side::Credit, "600.00"), ], ); (b, devices, laptop, camera) } /// Item L on the tax basis, per member, for the year (B-36): a cash /// contribution, a contribution of property, a distribution, a year of /// depreciation. #[test] fn tax_basis_capital_per_member() { let (b, _, _, _) = the_year(); let tc = tax_basis_capital( &b.l, date(2026, 1, 1), date(2026, 12, 31), &members(&b), &[b.cash], ) .unwrap(); let depreciation = Open::TaxDepreciation { book: d("1370.00"), contributed_book: d("1370.00"), }; assert_eq!(tc.open, vec![depreciation.clone()]); for row in &tc.rows { // The book column is the K-1 worksheet's row. assert_eq!(row.book.beginning, d("0")); assert_eq!(row.book.contributions_cash, d("1000.00")); assert_eq!(row.book.contributions_property, d("1370.00")); assert_eq!(row.book.net_income, d("-385.00")); assert_eq!(row.book.distributions, d("300.00")); assert_eq!(row.book.ending, d("1685.00")); // The tax column. assert_eq!(row.beginning.tax(), Some(d("0"))); assert_eq!(row.contributions_cash, d("1000.00")); assert_eq!(row.contributions_property.tax(), Some(d("1384.20"))); assert_eq!(row.net_income.tax(), None, "tax depreciation is the CPA's"); assert_eq!(row.net_income.known, d("-385.00")); 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![income, depreciation.clone()]); // Book less tax, by cause. assert_eq!( row.reconciliation, Reconciliation { beginning: d("0"), built_in_gain_704c: d("-14.20"), property_without_basis: d("0"), property_distributed: d("0"), } ); assert_eq!( row.reconciliation.total(), Some(row.book.ending - row.ending.known) ); } } /// Beginning capital the next year is this year's ending as the books /// know it, left open while an earlier year left a figure open. #[test] fn next_years_beginning_carries_what_is_known_and_says_what_isnt() { let (b, _, _, _) = the_year(); let tc = tax_basis_capital( &b.l, date(2027, 1, 1), date(2027, 12, 31), &members(&b), &[b.cash], ) .unwrap(); for row in &tc.rows { assert_eq!(row.book.beginning, d("1685.00")); assert_eq!(row.beginning.known, d("1699.20")); assert_eq!(row.beginning.open, vec![Open::PriorYears]); assert_eq!(row.reconciliation.beginning, d("-14.20")); assert_eq!( row.reconciliation.total(), Some(row.book.ending - row.ending.known) ); } } /// Cash only, no fixed assets: every line is the tax figure, equal to the /// book figure, 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_leave_only_the_tax_income_open() { let mut b = books(); let (cash, tobin, morgan, revenue) = (b.cash, b.tobin, b.morgan, b.revenue); post( &mut b.l, date(2026, 3, 1), &[ (cash, Side::Debit, "500.00"), (tobin, Side::Credit, "250.00"), (morgan, Side::Credit, "250.00"), ], ); post( &mut b.l, date(2026, 4, 1), &[ (cash, Side::Debit, "100.01"), (revenue, Side::Credit, "100.01"), ], ); let tc = tax_basis_capital( &b.l, date(2026, 1, 1), date(2026, 12, 31), &members(&b), &[cash], ) .unwrap(); assert!(tc.open.is_empty()); for (row, 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()); } } /// Property contributed with no basis in the books (a prepaid contract /// handed over, no registered asset) is open with its book amount; a /// linked asset carrying a purchase's basis on a contribution is open /// too. Neither is guessed. #[test] fn property_without_a_basis_is_open_never_guessed() { let mut b = books(); let (prepaid, equipment, tobin, morgan, cash) = (b.prepaid, b.equipment, b.tobin, b.morgan, b.cash); let contract = post( &mut b.l, date(2026, 1, 5), &[ (prepaid, Side::Debit, "36.00"), (tobin, Side::Credit, "18.00"), (morgan, Side::Credit, "18.00"), ], ); let tool = post( &mut b.l, date(2026, 1, 6), &[ (equipment, Side::Debit, "400.00"), (tobin, Side::Credit, "400.00"), ], ); let id = b.l.register_asset(NewAsset { description: "Drill".into(), in_service_date: date(2026, 1, 6), cost: usd("400.00"), salvage_value: usd("0.00"), useful_life_months: 24, cost_account: b.equipment, accum_depr_account: b.accum, expense_account: b.depreciation, }) .unwrap(); b.l.set_asset_acquisition(id, tool).unwrap(); b.l.set_asset_basis(id, AssetBasis::purchase()).unwrap(); let tc = tax_basis_capital( &b.l, date(2026, 1, 1), date(2026, 12, 31), &members(&b), &[cash], ) .unwrap(); let (t, m) = (&tc.rows[0], &tc.rows[1]); assert_eq!(t.book.contributions_property, d("418.00")); assert_eq!(t.contributions_property.known, d("0")); assert_eq!( t.contributions_property.open, vec![ Open::PropertyBasis { entry: contract, book: d("18.00") }, Open::PropertyBasis { entry: tool, book: d("400.00") }, ] ); 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("18.00")); for row in &tc.rows { assert_eq!( row.reconciliation.total(), Some(row.book.ending - row.ending.known) ); } } /// Property distributed: its adjusted tax basis is the CPA's (it needs /// tax depreciation), so the line is open with its book amount. #[test] fn property_distributed_is_open_at_its_book_amount() { let mut b = books(); let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); post( &mut b.l, date(2026, 1, 1), &[ (cash, Side::Debit, "1000.00"), (tobin, Side::Credit, "500.00"), (morgan, Side::Credit, "500.00"), ], ); post( &mut b.l, date(2026, 1, 2), &[ (equipment, Side::Debit, "100.00"), (cash, Side::Credit, "100.00"), ], ); let out = post( &mut b.l, date(2026, 6, 1), &[ (tobin, Side::Debit, "100.00"), (equipment, Side::Credit, "100.00"), ], ); let tc = tax_basis_capital( &b.l, date(2026, 1, 1), date(2026, 12, 31), &members(&b), &[cash], ) .unwrap(); let t = &tc.rows[0]; assert_eq!(t.book.distributions, d("100.00")); assert_eq!(t.distributions.known, d("0")); assert_eq!( t.distributions.open, vec![Open::PropertyDistribution { entry: out, book: d("100.00") }] ); assert_eq!(t.reconciliation.property_distributed, d("100.00")); assert_eq!( t.reconciliation.total(), Some(t.book.ending - t.ending.known) ); assert_eq!(tc.rows[1].distributions.tax(), Some(d("0"))); } /// tax_capital INVARIANTS 4 and 5 hold for every row of the year and of /// every span within it. #[test] fn every_span_rolls_forward_and_reconciles() { let (b, _, _, _) = the_year(); for (start, end) in [ (date(2026, 1, 1), date(2026, 6, 30)), (date(2026, 7, 1), date(2026, 12, 31)), (date(2026, 12, 15), date(2026, 12, 15)), ] { let tc = tax_basis_capital(&b.l, start, end, &members(&b), &[b.cash]).unwrap(); for row in &tc.rows { assert_eq!( row.ending.known, row.beginning.known + row.contributions_cash + row.contributions_property.known + row.net_income.known + row.other.known - row.distributions.known, "{start}..{end}" ); assert_eq!( row.reconciliation.total(), Some(row.book.ending - row.ending.known), "{start}..{end}" ); } } } /// 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()); }