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