btctax-forms 0.17.0

Fill the official IRS fillable PDFs (Form 8949 + Schedule D) from btctax's computed tax data — offline, deterministic, geometry-verified (part of btctax).
Documentation
//! Schedule D (Capital Gains and Losses) — the **FULL-RETURN** fill.
//!
//! This is not `schedule_d::fill_schedule_d_totals`, and the two must not be merged. That one is the
//! **crypto-slice** fill: it writes lines 3/7/10/15/16 from the ledger's disposal totals and nothing
//! else. For a crypto-only year that is complete and correct, and `export-irs-pdf` still uses it.
//!
//! For a FULL return it is not enough. The crypto slice has **no line 13** (1099-DIV box-2a
//! capital-gain distributions) and **no lines 6/14** (capital-loss carryovers) — yet all three ARE in
//! the computed return's 1040 line 7. A filer handed that form would mail a complete-looking Schedule
//! D with income missing. That is precisely the defect the P5-C1 refusal exists to cover, and this
//! module is what retires it.
//!
//! **★ Three parenthesized boxes: lines 6, 14 and 21.** The form pre-prints the parentheses, so they
//! ARE the minus sign, and the value written must be a POSITIVE MAGNITUDE — a negative renders as a
//! positive number on a filed return. [`assert_paren_magnitudes`] fails closed on one.
//!
//! **★ The amount column differs between the two pages of the same form.** Page 1's column (h) and its
//! standalone cells sit at x = [504, 576] (center 540); page 2's sit at x = [489.6, 576] (center
//! 532.8). One band covering both holds each center — but a band pinned to page 1 alone would reject
//! every page-2 cell.
//!
//! **★ Part III is a decision tree.** Which of lines 17–22 are answered depends on the sign of line 16
//! and the character of line 15 (SPEC §7.2). The routing is decided in core
//! ([`ScheduleDRouting`]) — this module only transcribes the branch it is given, so an impossible
//! combination cannot be filled.

use crate::cells::{push_identity, push_money, push_money_opt};
use crate::error::FormsError;
use crate::map::{MoneyCell, ScheduleDMap};
use crate::pdf;
use crate::verify::{verify_flat, FlatPlacement};
use btctax_core::tax::packet::ReturnHeader;
use btctax_core::tax::printed::{ScheduleDLines, ScheduleDRouting};
use btctax_core::Usd;

/// Logical Schedule D columns for the full fill.
const COL_PROCEEDS_D: usize = 0;
const COL_COST_E: usize = 1;
/// Column (h) AND every standalone amount cell, on BOTH pages — the band spans page 1's [504,576]
/// (center 540) and page 2's [489.6,576] (center 532.8), each of which falls inside it.
const COL_AMOUNT_H: usize = 2;

const SCHEDULE_D_CLUSTERS: &[(f32, f32)] = &[(288.0, 360.0), (360.0, 432.0), (489.0, 576.0)];

/// Descent groups. A row's (d)/(e)/(h) cells share a y, so each column descends in its own group; and
/// a page-2 y is not comparable with a page-1 y, so page 2 gets its own.
const GRP_P1_AMOUNT: u32 = 0;
const GRP_P1_D: u32 = 1;
const GRP_P1_E: u32 = 2;
const GRP_P2_AMOUNT: u32 = 3;

/// Fail closed if a parenthesized cell carries a negative. On the printed form the parentheses supply
/// the minus sign, so a negative would RENDER AS POSITIVE — turning a capital LOSS carryover into a
/// gain. Invisible to any geometric check; hence an explicit guard.
fn assert_paren_magnitudes(lines: &ScheduleDLines) -> Result<(), FormsError> {
    let mut cells = vec![("6", lines.line6), ("14", lines.line14)];
    if let ScheduleDRouting::NetLoss { line21, .. } = lines.routing {
        cells.push(("21", line21));
    }
    for (line, v) in cells {
        if v < Usd::ZERO {
            return Err(FormsError::Geometry(format!(
                "Schedule D line {line} is a PARENTHESIZED box (the form prints the minus sign), so it \
                 must carry a positive magnitude — got {v}. Writing this would render as a POSITIVE \
                 number on the filed return, turning a capital loss into a gain."
            )));
        }
    }
    Ok(())
}

/// A map cell the full fill requires but the crypto-slice maps (2017/2025) do not carry.
fn need<'a, T>(cell: &'a Option<T>, what: &str, year: i32) -> Result<&'a T, FormsError> {
    cell.as_ref().ok_or_else(|| {
        FormsError::Geometry(format!(
            "the TY{year} Schedule D map has no `{what}` — the full-return fill needs it. Full-return \
             v1 is TY2024-only."
        ))
    })
}

/// Fill the FULL-RETURN Schedule D from the core-derived printed chain, including Part III's routing.
/// The serialized bytes are read back through the geometric verifier (a mis-mapped cell FAILS CLOSED).
pub fn fill_schedule_d_full_with_map(
    lines: &ScheduleDLines,
    header: &ReturnHeader,
    map: &ScheduleDMap,
) -> Result<Vec<u8>, FormsError> {
    assert_paren_magnitudes(lines)?;
    let y = map.year;

    let mut writes: Vec<(String, pdf::FieldValue)> = Vec::new();
    let mut placements: Vec<FlatPlacement> = Vec::new();

    // ── Page 1, column (h) + the standalone amount cells, top to bottom. ────────────────────────
    // Column (g) (adjustments from Form 8949) is never written — v1 models no basis adjustment.
    // ★ §G-28/B4 — lines 1a and 8a lead their parts, so they take descent ordinals 0 and 3. Column (g)
    //   is absent from their map type ENTIRELY (`AmountColsNoAdjustment`): "no adjustments" is a
    //   condition of using those lines, so an adjustment cell there is unwritable by construction.
    // ★★ The 1a/8a cells are demanded ONLY when the filer actually has 1099-B totals. A pure-crypto
    //    return on a year whose map predates B4 must still file — but one carrying broker totals on
    //    such a year fails closed instead of dropping them off the page.
    let needs_1099b = lines.line1a_d != Usd::ZERO
        || lines.line1a_e != Usd::ZERO
        || lines.line8a_d != Usd::ZERO
        || lines.line8a_e != Usd::ZERO;
    // ★★★ AND WHEN THERE ARE NO 1099-B TOTALS, THE CELLS ARE NOT WRITTEN AT ALL.
    //
    //     Line 1a's own text ends *"However, if you choose to report all these transactions on Form
    //     8949, **leave this line blank** and go to line 1b."* It is a CONDITIONAL entry with an
    //     explicit blank clause, so a printed `0` is not a neutral zero — it swears the filer had
    //     Form 1099-B transactions, with basis reported to the IRS, totalling nothing.
    //
    //     ★★ The first draft took `map.line1a.as_ref()` here, which is `Some` for TY2024 because B4
    //     added the mapping — so EVERY pure-crypto return started printing that zero. Same §G-24 class
    //     this very file fixed for lines 18/19 a hundred lines below, reintroduced above it.
    let (c1a, c8a) = if needs_1099b {
        (
            Some(need(&map.line1a, "line1a", y)?),
            Some(need(&map.line8a, "line8a", y)?),
        )
    } else {
        (None, None)
    };

    // ★ A `Vec`, not a fixed array: lines 1a and 8a are present only when the year's map carries them,
    //   and an absent cell must produce NO WRITE rather than a write to an empty field name.
    let mut p1_amounts: Vec<(MoneyCell, Usd)> = Vec::new();
    if let Some(c) = c1a {
        p1_amounts.push((MoneyCell::Single(c.gain_h.clone()), lines.line1a_h));
    }
    p1_amounts.extend([
        (MoneyCell::Single(map.line3.gain_h.clone()), lines.line3_h),
        (need(&map.line6, "line6", y)?.clone(), lines.line6), // ★ PAREN
        (MoneyCell::Single(map.line7_h.clone()), lines.line7),
    ]);
    if let Some(c) = c8a {
        p1_amounts.push((MoneyCell::Single(c.gain_h.clone()), lines.line8a_h));
    }
    p1_amounts.extend([
        (MoneyCell::Single(map.line10.gain_h.clone()), lines.line10_h),
        (need(&map.line13, "line13", y)?.clone(), lines.line13),
        (need(&map.line14, "line14", y)?.clone(), lines.line14), // ★ PAREN
        (MoneyCell::Single(map.line15_h.clone()), lines.line15),
    ]);
    for (ord, (cell, value)) in p1_amounts.iter().enumerate() {
        push_money(
            &mut writes,
            &mut placements,
            cell,
            *value,
            COL_AMOUNT_H,
            Some((GRP_P1_AMOUNT, ord as u32)),
        );
    }

    // ── Page 1, columns (d) and (e) — the two 8949 totals rows. ─────────────────────────────────
    let mut p1_d: Vec<(&String, Usd)> = Vec::new();
    if let Some(c) = c1a {
        p1_d.push((&c.proceeds_d, lines.line1a_d));
    }
    p1_d.push((&map.line3.proceeds_d, lines.line3_d));
    if let Some(c) = c8a {
        p1_d.push((&c.proceeds_d, lines.line8a_d));
    }
    p1_d.push((&map.line10.proceeds_d, lines.line10_d));
    for (ord, (fqn, value)) in p1_d.iter().enumerate() {
        push_money(
            &mut writes,
            &mut placements,
            &MoneyCell::Single((*fqn).clone()),
            *value,
            COL_PROCEEDS_D,
            Some((GRP_P1_D, ord as u32)),
        );
    }
    let mut p1_e: Vec<(&String, Usd)> = Vec::new();
    if let Some(c) = c1a {
        p1_e.push((&c.cost_e, lines.line1a_e));
    }
    p1_e.push((&map.line3.cost_e, lines.line3_e));
    if let Some(c) = c8a {
        p1_e.push((&c.cost_e, lines.line8a_e));
    }
    p1_e.push((&map.line10.cost_e, lines.line10_e));
    for (ord, (fqn, value)) in p1_e.iter().enumerate() {
        push_money(
            &mut writes,
            &mut placements,
            &MoneyCell::Single((*fqn).clone()),
            *value,
            COL_COST_E,
            Some((GRP_P1_E, ord as u32)),
        );
    }

    // ── Page 2 — line 16, then Part III's routed branch (SPEC §7.2). ────────────────────────────
    let mut p2_ord = 0u32;
    let mut push_p2 = |cell: &MoneyCell,
                       value: Usd,
                       writes: &mut Vec<(String, pdf::FieldValue)>,
                       placements: &mut Vec<FlatPlacement>| {
        push_money(
            writes,
            placements,
            cell,
            value,
            COL_AMOUNT_H,
            Some((GRP_P2_AMOUNT, p2_ord)),
        );
        p2_ord += 1;
    };
    push_p2(
        &MoneyCell::Single(map.line16_h.clone()),
        lines.line16,
        &mut writes,
        &mut placements,
    );

    let check = |pair: &crate::map::YesNoPair,
                 yes: bool,
                 writes: &mut Vec<(String, pdf::FieldValue)>,
                 placements: &mut Vec<FlatPlacement>| {
        let choice = if yes { &pair.yes } else { &pair.no };
        writes.push((
            choice.field.clone(),
            pdf::FieldValue::Check {
                on: choice.on.clone(),
            },
        ));
        placements.push(FlatPlacement::check(choice.field.clone(), 1));
    };

    match lines.routing {
        // L16 > 0 and L15 > 0 — both gains. 17 = Yes; 18 = 19 = 0; 20 = Yes → QDCGT.
        // Lines 21 and 22 are NOT completed: the form says so in terms.
        ScheduleDRouting::BothGains => {
            check(
                need(&map.line17, "line17", y)?,
                true,
                &mut writes,
                &mut placements,
            );
            // ★★★ LINES 18 AND 19 ARE LEFT BLANK, and printing `0` here was fabricated testimony on
            //     EVERY crypto return with gains. Both are CONDITIONAL entries in the form's own
            //     words:
            //
            //       18  "If you are required to complete the 28% Rate Gain Worksheet (see
            //            instructions), enter the amount, if any, from line 7 of that worksheet"
            //       19  "If you are required to complete the Unrecaptured Section 1250 Gain Worksheet
            //            (see instructions), enter the amount, if any, from line 18 of that worksheet"
            //
            //     btctax is required to complete NEITHER — it refuses a return with §1202,
            //     collectibles or unrecaptured §1250 gain (`UnrecapturedOrSpecialRateGain`) — so the
            //     condition is unmet and the lines carry nothing. There is no `-0-` clause to invoke.
            //
            //     ★★ The form ITSELF says blank is expected: line 20 reads "Are lines 18 and 19 both
            //     zero **or blank** and you are not filing Form 4952?" — so leaving them empty answers
            //     line 20 exactly as a zero would, without swearing to an amount nobody computed.
            //
            //     ★ Same class as the §G-24 defect on 1040 lines 34/35a/37, and invisible for the same
            //     reason: no struct field carries these, so the line-coverage table never saw them.
            push_money_opt(
                &mut writes,
                &mut placements,
                need(&map.line18, "line18", y)?,
                None,
                COL_AMOUNT_H,
                None,
            );
            push_money_opt(
                &mut writes,
                &mut placements,
                need(&map.line19, "line19", y)?,
                None,
                COL_AMOUNT_H,
                None,
            );
            check(
                need(&map.line20, "line20", y)?,
                true,
                &mut writes,
                &mut placements,
            );
        }
        // L16 > 0, L15 ≤ 0 — 17 = No ⇒ skip 18 through 21 ⇒ 22.
        ScheduleDRouting::ShortGainLongLoss { line22_yes } => {
            check(
                need(&map.line17, "line17", y)?,
                false,
                &mut writes,
                &mut placements,
            );
            check(
                need(&map.line22, "line22", y)?,
                line22_yes,
                &mut writes,
                &mut placements,
            );
        }
        // L16 < 0 — skip 17 through 20; line 21 carries the §1211(b) offset (a MAGNITUDE); 22 answered.
        ScheduleDRouting::NetLoss { line21, line22_yes } => {
            push_p2(
                need(&map.line21, "line21", y)?,
                line21,
                &mut writes,
                &mut placements,
            );
            check(
                need(&map.line22, "line22", y)?,
                line22_yes,
                &mut writes,
                &mut placements,
            );
        }
        // L16 = 0 — skip 17 through 21; 22 answered.
        ScheduleDRouting::Zero { line22_yes } => {
            check(
                need(&map.line22, "line22", y)?,
                line22_yes,
                &mut writes,
                &mut placements,
            );
        }
    }

    let mut doc = pdf::load(pdf::schedule_d_pdf(map.year)?)?;
    let blank_fields = pdf::collect_fields(&doc)?;
    // The Schedule D map is SHARED with the crypto slice, whose 2017/2025 editions carry no verified
    // identity FQNs — hence `Option`. The FULL return may not emit an unnamed Schedule D, so a map
    // without an [identity] block fails closed here rather than producing one.
    let identity = map.identity.as_ref().ok_or_else(|| {
        FormsError::Geometry(format!(
            "the {} Schedule D map has no [identity] block — a full return cannot file an unnamed schedule",
            map.year
        ))
    })?;
    push_identity(
        &mut writes,
        &mut placements,
        identity,
        &header.name_line,
        &header.taxpayer.ssn,
        &blank_fields,
    )?;
    // ★ The QOF question — "Did you dispose of any investment(s) in a qualified opportunity fund…?" The
    // crypto slice has always answered No; the full path was leaving a MANDATORY header question blank on
    // identical ledger knowledge (bitcoin-only, no QOF). Fable P6 r1 I4.
    if let Some(qof_no) = &map.qof_no {
        writes.push((
            qof_no.field.clone(),
            pdf::FieldValue::Check {
                on: qof_no.on.clone(),
            },
        ));
        placements.push(FlatPlacement::check(
            qof_no.field.clone(),
            crate::cells::page_of(&qof_no.field),
        ));
    }

    let index = pdf::index(&blank_fields);
    pdf::drop_xfa_and_set_needappearances(&mut doc)?;
    pdf::apply_writes(&mut doc, &index, &writes)?;
    pdf::strip_nondeterminism(&mut doc);
    let bytes = pdf::save(&mut doc)?;

    let doc2 = pdf::load(&bytes)?;
    let fields = pdf::collect_fields(&doc2)?;
    verify_flat(&doc2, &fields, &placements, SCHEDULE_D_CLUSTERS)?;
    Ok(bytes)
}