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

+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 {