tax summary: Schedule K items stated separately; XLSX never shows a wrong amount
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:
1 parent
d99589e6c1
commit
96c70f727a
9 files changed
+195
-8
No files matched your search
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user