tax summary: Schedule K items stated separately; XLSX never shows a wrong amount
check / check (push) Canceled after 0s

From the IRS form instructions (now quoted in 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 the entity
  "must state separately". The tax summary counted both in ordinary
  income and deductions; they now go in TaxSummary::separately_stated,
  outside book_net and tax_net, and the report, MCP export, and viewer
  list them under "Separately stated to owners (Schedule K)".
- Schedule C: "Do not include charitable contributions". The category no
  longer applies to Schedule C (a sole proprietor's gift is personal, an
  owner draw).
- interest_income's note: interest earned in the business itself is
  ordinary income (other_income).

XLSX: a cell number is a binary double and Excel honors 15 significant
digits (Microsoft, "Excel specifications and limits"), so an amount over
$9,999,999,999,999.99 or finer than a cent is written as its exact text,
right-aligned, instead of a number Excel would show wrong. The old
conversion also fell back to 0.0 silently; gone. Property test: every
whole-cent amount up to 15 digits round-trips exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 20:57:00 -06:00
1 parent c72a762ef1
commit ea180d1d8e
9 files changed
+195 -8

No files matched your search

+26 -3
View File
@@ -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<TaxSummaryLine>,
/// Deductions.
pub deductions: Vec<TaxSummaryLine>,
/// 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<TaxSummaryLine>,
/// P&L accounts with activity and no category.
pub uncategorized: Vec<Uncategorized>,
}
@@ -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<TaxForm>) -> bool {
matches!(form, Some(TaxForm::Form1065 | TaxForm::Form1120S))
&& matches!(key, "interest_income" | "charitable_contributions")
}
+78
View File
@@ -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()
);
}
+7
View File
@@ -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 {
+3
View File
@@ -20,3 +20,6 @@ typst-layout = "0.15.1"
[lints]
workspace = true
[dev-dependencies]
proptest = "1"
+8
View File
@@ -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"));
+43 -3
View File
@@ -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<f64> {
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 {
+2
View File
@@ -3994,6 +3994,7 @@ impl BooksServer {
"income": ts.income.iter().map(line).collect::<Vec<_>>(),
"cost_of_goods": ts.cost_of_goods.iter().map(line).collect::<Vec<_>>(),
"deductions": ts.deductions.iter().map(line).collect::<Vec<_>>(),
"separately_stated": ts.separately_stated.iter().map(line).collect::<Vec<_>>(),
"uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({
"account": u.account, "book": money(u.book),
})).collect::<Vec<_>>(),
@@ -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!(