Skip to main content

btctax_forms/
fill8949.rs

1//! Form 8949 fill: format the tax data into grid cells, authorize each write against the per-year
2//! map, fill the bundled PDF, and READ BACK the serialized bytes through the geometric verifier.
3//!
4//! **Digital-asset box:** Bitcoin is filed under **Box I** (short-term) / **Box L** (long-term) —
5//! the 2025 1099-DA revision — NEVER Box C/F ("other than digital asset transactions"). The box is
6//! chosen by the part's on-state in the map, so the core `Form8949Box::{C,F}` taxonomy is not reused.
7
8use crate::error::FormsError;
9use crate::map::{Form8949Map, PartMap};
10use crate::verify::{verify_8949, Geo, Placement};
11use crate::{fmt_date, pdf};
12use btctax_core::forms::Form8949Row;
13use btctax_core::Form8949Part;
14use rust_decimal::Decimal;
15
16/// One part's formatted rows + totals, ready to place on the form.
17pub struct PartData {
18    /// Each row's 8 column strings (a..h); an empty string means "leave that cell blank".
19    pub rows: Vec<[String; 8]>,
20    /// Totals row: (d) proceeds, (e) cost, (g) adjustment, (h) gain — as display strings.
21    pub totals: [String; 4],
22    /// Whether the (g) adjustment total is non-zero (else that cell stays blank).
23    pub adj_nonzero: bool,
24}
25
26/// Column indices on Form 8949 (a=0 … h=7).
27const COL_D: usize = 3;
28const COL_E: usize = 4;
29const COL_G: usize = 6;
30const COL_H: usize = 7;
31
32/// Format one core row into its 8 column strings. Money is the exact `Decimal` Display (identical to
33/// the `form8949.csv`); dates render MM/DD/YYYY (the form's native date format); the (f) code and a
34/// zero (g) adjustment stay blank per IRS convention.
35fn row_cells(r: &Form8949Row) -> Result<[String; 8], FormsError> {
36    Ok([
37        r.description.clone(),
38        fmt_date(r.date_acquired)?,
39        fmt_date(r.date_sold)?,
40        r.proceeds.to_string(),
41        r.cost_basis.to_string(),
42        r.adjustment_code.clone(),
43        if r.adjustment_amount.is_zero() {
44            String::new()
45        } else {
46            r.adjustment_amount.to_string()
47        },
48        r.gain.to_string(),
49    ])
50}
51
52/// Build one part's `PartData` from its rows.
53pub fn part_data(rows: &[&Form8949Row]) -> Result<PartData, FormsError> {
54    let mut out = Vec::with_capacity(rows.len());
55    let (mut sp, mut sc, mut sg, mut sh) =
56        (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
57    for r in rows {
58        out.push(row_cells(r)?);
59        sp += r.proceeds;
60        sc += r.cost_basis;
61        sg += r.adjustment_amount;
62        sh += r.gain;
63    }
64    Ok(PartData {
65        rows: out,
66        totals: [
67            sp.to_string(),
68            sc.to_string(),
69            sg.to_string(),
70            sh.to_string(),
71        ],
72        adj_nonzero: !sg.is_zero(),
73    })
74}
75
76/// Accumulate the writes + placements for one part onto its map page.
77fn place_part(
78    part: &PartMap,
79    data: &PartData,
80    writes: &mut Vec<(String, pdf::FieldValue)>,
81    placements: &mut Vec<Placement>,
82) {
83    if data.rows.is_empty() {
84        return; // nothing to report on this part — leave the box + totals blank
85    }
86    // Digital-asset box (Box I / Box L).
87    writes.push((
88        part.box_field.clone(),
89        pdf::FieldValue::Check {
90            on: part.box_on.clone(),
91        },
92    ));
93    placements.push(Placement {
94        fqn: part.box_field.clone(),
95        geo: Geo::Check,
96    });
97    // Data rows.
98    for (ri, cells) in data.rows.iter().enumerate() {
99        for (ci, value) in cells.iter().enumerate() {
100            if value.is_empty() {
101                continue; // blank cell — do not write, do not authorize
102            }
103            let fqn = part.rows[ri][ci].clone();
104            writes.push((fqn.clone(), pdf::FieldValue::Text(value.clone())));
105            placements.push(Placement {
106                fqn,
107                geo: Geo::Data { row: ri, col: ci },
108            });
109        }
110    }
111    // Per-part totals (the line-2 text says "Enter each total here").
112    let totals = [
113        (COL_D, &part.totals.proceeds_d, true),
114        (COL_E, &part.totals.cost_e, true),
115        (COL_G, &part.totals.adj_g, data.adj_nonzero),
116        (COL_H, &part.totals.gain_h, true),
117    ];
118    let vals = [
119        &data.totals[0],
120        &data.totals[1],
121        &data.totals[2],
122        &data.totals[3],
123    ];
124    for (i, (col, fqn, include)) in totals.into_iter().enumerate() {
125        if !include {
126            continue;
127        }
128        writes.push((fqn.clone(), pdf::FieldValue::Text(vals[i].clone())));
129        placements.push(Placement {
130            fqn: fqn.clone(),
131            geo: Geo::Total { col },
132        });
133    }
134}
135
136/// Fill Form 8949 (Part I + Part II, ≤ 11 rows each) into the bundled TY2025 PDF and return the
137/// serialized bytes. The output is read back through the geometric verifier — a mis-mapped cell or a
138/// stray write FAILS CLOSED (no bytes returned). Pagination for > 11 rows is handled by
139/// [`crate::fill_form_8949`], which chunks before calling this.
140pub fn fill_8949_parts(
141    short: &PartData,
142    long: &PartData,
143    map: &Form8949Map,
144) -> Result<Vec<u8>, FormsError> {
145    fill_8949_parts_inner(short, long, map, None)
146}
147
148/// As [`fill_8949_parts`], with the FILER's identity written on **both pages** — the full-return path.
149/// An unnamed 8949 is not filable, and it is a TWO-page form: each page carries its own
150/// "Name(s) shown on return" + SSN header (Fable P6 r1 I3).
151pub fn fill_8949_parts_with_identity(
152    short: &PartData,
153    long: &PartData,
154    map: &Form8949Map,
155    header: &btctax_core::tax::packet::ReturnHeader,
156) -> Result<Vec<u8>, FormsError> {
157    fill_8949_parts_inner(short, long, map, Some(header))
158}
159
160fn fill_8949_parts_inner(
161    short: &PartData,
162    long: &PartData,
163    map: &Form8949Map,
164    filer: Option<&btctax_core::tax::packet::ReturnHeader>,
165) -> Result<Vec<u8>, FormsError> {
166    if short.rows.len() > map.rows_per_page {
167        return Err(FormsError::Overflow {
168            part: "Part I",
169            rows: short.rows.len(),
170            capacity: map.rows_per_page,
171        });
172    }
173    if long.rows.len() > map.rows_per_page {
174        return Err(FormsError::Overflow {
175            part: "Part II",
176            rows: long.rows.len(),
177            capacity: map.rows_per_page,
178        });
179    }
180
181    let mut writes: Vec<(String, pdf::FieldValue)> = Vec::new();
182    let mut placements: Vec<Placement> = Vec::new();
183    if let Some(p) = map.part("short") {
184        place_part(p, short, &mut writes, &mut placements);
185    }
186    if let Some(p) = map.part("long") {
187        place_part(p, long, &mut writes, &mut placements);
188    }
189
190    let mut doc = pdf::load(pdf::f8949_pdf(map.year)?)?;
191    let blank_fields = pdf::collect_fields(&doc)?;
192
193    // The filer's identity, on BOTH pages. `Geo::Check` = authorized + in the no-unmapped set, but
194    // excluded from the column-geometry oracle (an identity cell is in no data column).
195    if let Some(header) = filer {
196        for cells in [&map.identity_page1, &map.identity_page2] {
197            let cells = cells.as_ref().ok_or_else(|| {
198                FormsError::Geometry(format!(
199                    "the {} Form 8949 map has no [identity] block — a full return cannot file an \
200                     unnamed Form 8949",
201                    map.year
202                ))
203            })?;
204            let ssn = crate::cells::render_ssn(
205                &header.taxpayer.ssn,
206                blank_fields
207                    .iter()
208                    .find(|f| f.fqn == cells.ssn)
209                    .and_then(|f| f.max_len),
210            )?;
211            writes.push((
212                cells.name.clone(),
213                pdf::FieldValue::Text(header.name_line.clone()),
214            ));
215            placements.push(Placement {
216                fqn: cells.name.clone(),
217                geo: crate::verify::Geo::Check,
218            });
219            writes.push((cells.ssn.clone(), pdf::FieldValue::Text(ssn)));
220            placements.push(Placement {
221                fqn: cells.ssn.clone(),
222                geo: crate::verify::Geo::Check,
223            });
224        }
225    }
226
227    let index = pdf::index(&blank_fields);
228    pdf::drop_xfa_and_set_needappearances(&mut doc)?;
229    pdf::apply_writes(&mut doc, &index, &writes)?;
230    pdf::strip_nondeterminism(&mut doc);
231    let bytes = pdf::save(&mut doc)?;
232
233    // True read-back: re-parse the SERIALIZED output and verify geometry against the PDF's own rects.
234    let check = pdf::load(&bytes)?;
235    let fields = pdf::collect_fields(&check)?;
236    verify_8949(&check, &fields, &placements, &map.table_token)?;
237    // XFA must be gone on the actual output.
238    if pdf_has_xfa(&check)? {
239        return Err(FormsError::Structure(
240            "output still carries /XFA after fill".into(),
241        ));
242    }
243    Ok(bytes)
244}
245
246/// Whether the document's AcroForm still carries an `/XFA` key.
247pub fn pdf_has_xfa(doc: &lopdf::Document) -> Result<bool, FormsError> {
248    let acro = match doc.catalog()?.get(b"AcroForm") {
249        Ok(lopdf::Object::Reference(id)) => doc.get_dictionary(*id)?,
250        Ok(lopdf::Object::Dictionary(d)) => d,
251        _ => return Ok(false),
252    };
253    Ok(acro.has(b"XFA"))
254}
255
256/// Split the year's rows into Part I (short-term) and Part II (long-term), preserving the core's
257/// deterministic order.
258pub fn split_parts(rows: &[Form8949Row]) -> (Vec<&Form8949Row>, Vec<&Form8949Row>) {
259    let mut st = Vec::new();
260    let mut lt = Vec::new();
261    for r in rows {
262        match r.part {
263            Form8949Part::ShortTerm => st.push(r),
264            Form8949Part::LongTerm => lt.push(r),
265        }
266    }
267    (st, lt)
268}