diff --git a/Cargo.lock b/Cargo.lock index 0db44a9..22e5d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3524,6 +3524,7 @@ dependencies = [ "chrono", "csv", "ledger-core", + "proptest", "rust_decimal", "rust_xlsxwriter", "serde", diff --git a/crates/ledger-core/src/tax.rs b/crates/ledger-core/src/tax.rs index eaf0824..1e301bc 100644 --- a/crates/ledger-core/src/tax.rs +++ b/crates/ledger-core/src/tax.rs @@ -99,6 +99,8 @@ pub struct TaxCategory { const ALL: &[TaxForm] = &TaxForm::ALL; const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120]; +/// Every return but Schedule C. +const ENTITIES: &[TaxForm] = &[TaxForm::Form1065, TaxForm::Form1120S, TaxForm::Form1120]; const fn cat( key: &'static str, @@ -144,7 +146,7 @@ pub const CATEGORIES: &[TaxCategory] = &[ Income, ALL, 100, - "Pass-throughs report it separately to owners (portfolio income).", + "Portfolio interest: partnerships and S corps state it separately (Schedule K). Interest earned in the business itself (on customer accounts, say) is ordinary income: use other_income.", ), cat("other_income", "Other income", Income, ALL, 100, ""), cat( @@ -176,9 +178,9 @@ pub const CATEGORIES: &[TaxCategory] = &[ "charitable_contributions", "Charitable contributions", Deduction, - ALL, + ENTITIES, 100, - "Separately stated to owners for partnerships and S corps; limited for C corps; not a Schedule C deduction.", + "Separately stated to owners for partnerships and S corps (Schedule K); limited for C corps. Not on Schedule C (\"Do not include charitable contributions\"): a sole proprietor's gift is personal, an owner draw.", ), cat( "commissions_fees", @@ -419,6 +421,12 @@ pub struct TaxSummary { pub cost_of_goods: Vec, /// Deductions. pub deductions: Vec, + /// Items a partnership or S corporation states separately to its owners + /// (Schedule K) instead of netting them in ordinary income: portfolio + /// interest and charitable contributions (docs/spec/sources/ + /// irs-tax-lines.md). Not in [`TaxSummary::book_net`] or `tax_net`. + #[serde(default)] + pub separately_stated: Vec, /// P&L accounts with activity and no category. pub uncategorized: Vec, } @@ -497,11 +505,16 @@ pub fn tax_summary( income: vec![], cost_of_goods: vec![], deductions: vec![], + separately_stated: vec![], uncategorized, }; // Catalog order, so the summary reads like the return. for c in CATEGORIES { if let Some(line) = by_cat.remove(c.key) { + if separately_stated(c.key, form) { + out.separately_stated.push(line); + continue; + } match c.kind { TaxKind::Income => out.income.push(line), TaxKind::CostOfGoods => out.cost_of_goods.push(line), @@ -511,3 +524,13 @@ pub fn tax_summary( } Ok(out) } + +/// Whether a partnership or S corporation states this category separately +/// to its owners (Schedule K) rather than in ordinary income. Form 1065 +/// and 1120-S instructions: Schedule K takes "only taxable portfolio +/// interest", and "charitable contributions" are among the "items ... must +/// state separately". +pub fn separately_stated(key: &str, form: Option) -> bool { + matches!(form, Some(TaxForm::Form1065 | TaxForm::Form1120S)) + && matches!(key, "interest_income" | "charitable_contributions") +} diff --git a/crates/ledger-core/tests/tax.rs b/crates/ledger-core/tests/tax.rs index 8934602..1cda2fa 100644 --- a/crates/ledger-core/tests/tax.rs +++ b/crates/ledger-core/tests/tax.rs @@ -264,3 +264,81 @@ fn forms_have_their_irs_names() { assert_eq!(TaxForm::parse(f.label()), Some(f)); } } + +/// A small book with portfolio interest (125) and a donation (300) besides +/// sales (1,000), for where each lands by return. +fn interest_and_gift() -> Ledger { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let mut acct = |code: &str, t, cat: &str| { + let id = l.add_account(code, code, t, None, false).unwrap(); + l.set_account_tax(id, Some(cat), None).unwrap(); + id + }; + let sales = acct("4000", AccountType::Revenue, "gross_receipts"); + let interest = acct("4200", AccountType::Revenue, "interest_income"); + let gift = acct("6900", AccountType::Expense, "charitable_contributions"); + let usd = |s: &str| Money::new(dec(s), Currency::USD); + for (dr, cr, amt) in [ + (cash, sales, "1000"), + (cash, interest, "125"), + (gift, cash, "300"), + ] { + let e = l.create_entry(d(2, 1), "x"); + l.add_line(e, dr, Side::Debit, usd(amt)).unwrap(); + l.add_line(e, cr, Side::Credit, usd(amt)).unwrap(); + l.post_entry(e).unwrap(); + } + l +} + +/// Oracle: docs/spec/sources/irs-tax-lines.md. On Forms 1065 and 1120-S, +/// Schedule K takes "only taxable portfolio interest", and charitable +/// contributions are among the "items ... must state separately": both +/// leave the ordinary-income net (1,000 here, not 825) and are listed +/// apart. On Form 1120 they stay in it (lines 5 and 19). +#[test] +fn pass_throughs_state_interest_and_gifts_separately() { + let l = interest_and_gift(); + for form in [TaxForm::Form1065, TaxForm::Form1120S] { + let ts = tax_summary(&l, d(1, 1), d(12, 31), Some(form)).unwrap(); + let keys: Vec<&str> = ts + .separately_stated + .iter() + .map(|x| x.category.as_str()) + .collect(); + assert_eq!( + keys, + ["interest_income", "charitable_contributions"], + "{form:?}" + ); + assert_eq!(ts.book_net(), dec("1000"), "{form:?}"); + } + let ts = tax_summary(&l, d(1, 1), d(12, 31), Some(TaxForm::Form1120)).unwrap(); + assert!(ts.separately_stated.is_empty()); + assert_eq!(ts.book_net(), dec("825")); +} + +/// Oracle: Schedule C instructions, Part V ("Do not include charitable +/// contributions"): the category is refused for a Schedule C business. +#[test] +fn a_sole_proprietor_cannot_deduct_a_gift_on_schedule_c() { + assert!(matches!( + check_category( + "charitable_contributions", + AccountType::Expense, + Some(TaxForm::ScheduleC) + ), + Err(LedgerError::InvalidTaxCategory(_)) + )); + assert!( + check_category( + "charitable_contributions", + AccountType::Expense, + Some(TaxForm::Form1120) + ) + .is_ok() + ); +} diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index a6d9529..8d038ba 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -863,6 +863,13 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { net, vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())], ); + if !ts.separately_stated.is_empty() { + sec( + &mut t, + "Separately stated to owners (Schedule K)", + &ts.separately_stated, + ); + } if !ts.uncategorized.is_empty() { t.section("Not categorized"); for u in &ts.uncategorized { diff --git a/crates/ledger-import/Cargo.toml b/crates/ledger-import/Cargo.toml index b41f043..e9e4fdd 100644 --- a/crates/ledger-import/Cargo.toml +++ b/crates/ledger-import/Cargo.toml @@ -20,3 +20,6 @@ typst-layout = "0.15.1" [lints] workspace = true + +[dev-dependencies] +proptest = "1" diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs index c6baa1f..cfcf33c 100644 --- a/crates/ledger-import/src/report.rs +++ b/crates/ledger-import/src/report.rs @@ -460,6 +460,14 @@ pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement { diff(ts.book_net(), ts.tax_net()), ], )); + if !ts.separately_stated.is_empty() { + rows.push(spacer()); + rows.push(section("Separately stated to owners (Schedule K)")); + rows.push(subheading( + "Portfolio interest and charitable contributions; not included above:", + )); + rows.extend(lines(&ts.separately_stated)); + } if !ts.uncategorized.is_empty() { rows.push(spacer()); rows.push(section("Not yet categorized")); diff --git a/crates/ledger-import/src/xlsx.rs b/crates/ledger-import/src/xlsx.rs index c729cb1..da33a65 100644 --- a/crates/ledger-import/src/xlsx.rs +++ b/crates/ledger-import/src/xlsx.rs @@ -138,6 +138,11 @@ fn footer( Ok(()) } +/// Write an amount. A number cell is a binary double and Excel honors 15 +/// significant digits (docs/spec/sources/xlsx-numbers.md), so an amount +/// with more than 13 digits before the cents can't show exactly: such an +/// amount is written as its exact text instead, right-aligned, so the +/// spreadsheet never shows a wrong number. fn number( sheet: &mut Worksheet, row: u32, @@ -145,7 +150,15 @@ fn number( d: Decimal, fmt: &Format, ) -> Result<(), XlsxError> { - sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?; + match exact_f64(d) { + Some(x) => sheet.write_number_with_format(row, col, x, fmt)?, + None => sheet.write_string_with_format( + row, + col, + crate::report::format_amount(d), + &fmt.clone().set_align(rust_xlsxwriter::FormatAlign::Right), + )?, + }; Ok(()) } @@ -369,9 +382,15 @@ fn general_journal( } /// Decimal to f64 at the presentation boundary. Rounded to cents first. -fn dec_to_f64(d: Decimal) -> f64 { +/// The double a cell holds for `d`, when Excel will show it exactly: whole +/// cents, at most 15 significant digits. None otherwise. +fn exact_f64(d: Decimal) -> Option { use rust_decimal::prelude::ToPrimitive; - d.round_dp(2).to_f64().unwrap_or(0.0) + let cents = d.round_dp(2); + if cents != d || cents.abs() > Decimal::new(999_999_999_999_999, 2) { + return None; + } + cents.to_f64() } #[cfg(test)] @@ -380,6 +399,27 @@ mod tests { use crate::report; use ledger_core::reports::{TrialBalance, TrialBalanceLine}; + proptest::proptest! { + /// Oracle: docs/spec/sources/xlsx-numbers.md (a cell is a binary + /// double; Excel honors 15 significant digits). Every whole-cent + /// amount up to 15 digits comes back exactly when shown to the + /// cent; anything finer than a cent, or longer, is not written as a + /// number at all. + #[test] + fn a_cent_amount_shows_exactly_or_is_written_as_text( + cents in -999_999_999_999_999i64..=999_999_999_999_999, + extra in 1i64..10, + ) { + let d = Decimal::new(cents, 2); + let x = exact_f64(d).expect("15 digits fit"); + proptest::prop_assert_eq!(format!("{x:.2}"), format!("{d:.2}")); + proptest::prop_assert!(exact_f64(Decimal::new(cents * 10 + extra, 3)).is_none()); + let big = Decimal::new(999_999_999_999_999, 2) + Decimal::new(extra, 2); + proptest::prop_assert!(exact_f64(big).is_none()); + proptest::prop_assert!(exact_f64(-big).is_none()); + } + } + #[test] fn xlsx_is_a_zip() { let tb = TrialBalance { diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 3715d75..46e7704 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -3994,6 +3994,7 @@ impl BooksServer { "income": ts.income.iter().map(line).collect::>(), "cost_of_goods": ts.cost_of_goods.iter().map(line).collect::>(), "deductions": ts.deductions.iter().map(line).collect::>(), + "separately_stated": ts.separately_stated.iter().map(line).collect::>(), "uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({ "account": u.account, "book": money(u.book), })).collect::>(), @@ -4007,6 +4008,7 @@ impl BooksServer { ("income", &ts.income), ("cost_of_goods", &ts.cost_of_goods), ("deductions", &ts.deductions), + ("separately_stated", &ts.separately_stated), ] { for l in lines { csv.push_str(&format!( diff --git a/docs/spec/sources/irs-tax-lines.md b/docs/spec/sources/irs-tax-lines.md index 93bdbf8..13c23b7 100644 --- a/docs/spec/sources/irs-tax-lines.md +++ b/docs/spec/sources/irs-tax-lines.md @@ -325,6 +325,31 @@ SHA-256 of the retrieved file (`i1120.pdf`): `49476a6f69a38a0fdb96fea1d66ddc9364 --- +## Separately stated items on the pass-through returns (Schedule K) +Source: +Retrieved: 2026-10-13 +SHA-256 of the retrieved file (`i1065.pdf`): `f4a35a24c7abac486908d3610754397a66b2130f0eb425dda897d78a9d91b786` + +**Portfolio income (Form 1065 instructions)** + +> Generally, portfolio income includes all gross income, other than income derived in the ordinary course of a trade or business, that is attributable to interest; dividends; royalties; income from a real estate investment trust (REIT), a regulated investment company (RIC), a REMIC, a common trust fund, a CFC, a QEF, or a cooperative; income from the disposition of property that produces income of a type defined as portfolio income; and income from the disposition of property held for investment. + +**Schedule K, line 5, Interest Income (Form 1065 instructions)** + +> Enter only taxable portfolio interest on this line. + +Source: +Retrieved: 2026-10-13 +SHA-256 of the retrieved file (`i1120s.pdf`): `eab655cff9226dbc6d80213350bac050009ba90daf621c182d31c541bc62c3f2` + +**Separately stated items (Form 1120-S instructions)** + +> Items the corporation must state separately that require separate computations by the shareholders. Examples include expenses incurred for the production of income instead of in a trade or business, charitable contributions, foreign taxes paid or accrued, intangible drilling and development costs, soil and water conservation expenditures, amortizable basis of reforestation expenditures, and exploration expenditures. + +**Schedule K, line 4, Interest Income (Form 1120-S instructions)** + +> Enter only taxable portfolio interest on this line. Taxable interest is interest from all sources except interest exempt from tax and interest on tax-free covenant bonds. + ## Mapping (wanderbooks, derived) **This section is derived, not quoted.** It is wanderbooks' reading of the 2025 forms and instructions above, written 2026-10-13, for the categories in `crates/ledger-core/src/tax.rs`. It is not tax advice; the CPA decides the return. "—" means the category does not apply to that form (per `TaxCategory::forms`). "Not page 1" means the quoted page-1 lines have no place for it; where it goes instead (for example Schedule K) is not quoted in this file and is left to the CPA. @@ -333,13 +358,13 @@ SHA-256 of the retrieved file (`i1120.pdf`): `49476a6f69a38a0fdb96fea1d66ddc9364 |---|---|---|---|---| | `gross_receipts` | 1 | 1a | 1a | 1a | | `returns_allowances` | 2 | 1b | 1b | 1b | -| `interest_income` | 6 for interest on notes and accounts receivable (instructions, line 6); other interest not determined here | Not page 1 (separately stated; not quoted) | Not page 1 (not quoted) | 5 | +| `interest_income` | 6 for interest on notes and accounts receivable (instructions, line 6); other interest not determined here | Not page 1: Schedule K line 5, portfolio interest (quoted above) | Not page 1: Schedule K line 4, portfolio interest (quoted above) | 5 | | `other_income` | 6 | 7 | 5 | 10 | | `cost_of_goods_sold` | 4 (from Part III, line 42) | 2 | 2 | 2 | | `advertising` | 8 | 21 (no advertising line) | 16 | 22 | | `bad_debts` | 27b via Part V (no line in Part II; instructions list bad debts under Part V) | 12 | 10 | 15 | | `car_truck` | 9 | 21 | 20 | 26 | -| `charitable_contributions` | Not deductible on Schedule C (Part V: "Do not include charitable contributions") | Not page 1: separately stated (instructions) | Not page 1 (not quoted) | 19 | +| `charitable_contributions` | Not deductible on Schedule C (Part V: "Do not include charitable contributions") | Not page 1: separately stated (instructions) | Not page 1: separately stated (quoted above) | 19 | | `commissions_fees` | 10 | 21 | 20 | 26 | | `contract_labor` | 11 | 21 | 20 | 26 | | `depreciation` | 13 (tax amount, not book; CPA) | 16a/16c (tax amount; CPA) | 14 (tax amount; CPA) | 20 (tax amount; CPA) |