Skip to main content

btctax_forms/
lib.rs

1//! **btctax-forms** — fill the OFFICIAL IRS fillable PDFs (Form 8949 + Schedule D) from btctax's
2//! already-computed tax data. Offline, deterministic, and **geometry-verified**: every fill is read
3//! back from its own serialized bytes and each value's widget `/Rect` is checked against a
4//! map-independent column/row band re-derived from the PDF itself. A mis-mapped cell fails closed.
5//!
6//! ## Sub-project 1 (TY2025)
7//! - **Form 8949** — Bitcoin under **Box I** (short-term) / **Box L** (long-term), the 1099-DA
8//!   revision (NOT Box C/F, which read "other than digital asset transactions"). 11 rows per part
9//!   per page.
10//! - **Schedule D** — lines 3 & 7 (ST), 10 & 15 (LT), 16 (total), QOF = No. Lines 17-22 are scoped
11//!   out (the caller prints a notice).
12//! - These are **static XFA-hybrid** PDFs: the fill removes the `/AcroForm` `/XFA` layer (else
13//!   Acrobat shows blank), sets `/NeedAppearances`, and pins determinism (drops `/Info` dates + the
14//!   trailer `/ID`).
15//!
16//! The tax data is REUSED verbatim from the projection (`btctax_core::form_8949` /
17//! `btctax_core::schedule_d`) — this crate never recomputes gains.
18
19mod cells;
20mod error;
21mod fill8949;
22mod fill8949_full;
23mod form1040;
24mod form1040_full;
25mod form8283;
26mod form8959;
27mod form8960;
28mod form8995;
29mod map;
30mod overflow;
31mod packet;
32mod pdf;
33mod schedule23;
34mod schedule_a;
35mod schedule_b;
36mod schedule_c;
37mod schedule_d;
38mod schedule_d_full;
39mod schedule_se;
40mod schedule_se_full;
41mod transcribe;
42mod verify;
43mod watermark;
44
45pub use error::FormsError;
46pub use form1040::{Form1040Fill, Form1040Inputs};
47pub use map::{
48    Form1040Map, Form8283Map, Form8949Map, Form8959Map, Form8960Map, Form8995Map, Schedule1Map,
49    Schedule2Map, Schedule3Map, ScheduleAMap, ScheduleBMap, ScheduleCMap, ScheduleDMap,
50    ScheduleSeMap,
51};
52pub use schedule_se::SE_FLOOR;
53
54use btctax_core::conventions::{TaxDate, Usd};
55use btctax_core::{Form8949Row, ScheduleDTotals};
56use time::macros::format_description;
57
58/// The tax years this build bundles forms + maps for. Each fill dispatches to the year's committed
59/// map + bundled PDF via the `Map::for_year` constructors; an unlisted year fails closed with
60/// [`FormsError::UnsupportedYear`].
61pub const SUPPORTED_YEARS: &[i32] = &[2017, 2024, 2025];
62
63/// Format a date as **MM/DD/YYYY** — Form 8949's native date format for columns (b)/(c).
64pub(crate) fn fmt_date(d: TaxDate) -> Result<String, FormsError> {
65    let fmt = format_description!("[month]/[day]/[year]");
66    d.format(&fmt)
67        .map_err(|e| FormsError::Structure(format!("date format: {e}")))
68}
69
70/// Format money exactly as the `form8949.csv` / `schedule_d.csv` do — the raw `Decimal` Display
71/// (native scale, no `$`, no thousands separators). Keeping this identical to the CSV is what lets
72/// `schedule_d_totals_match_form8949_and_csv` cross-check the three artifacts.
73pub(crate) fn fmt_money(d: Usd) -> String {
74    d.to_string()
75}
76
77/// Fill **Form 8949** (Part I + Part II) for `year` from the projection rows and return the PDF
78/// bytes. Bitcoin is filed under Box I/L. Parts with > 11 rows paginate: ⌈rows/11⌉ page copies per
79/// part, each with its own totals; the copies are merged with per-copy field renaming so no two share
80/// a value. Every copy is geometry-verified before merge.
81pub fn fill_form_8949(rows: &[Form8949Row], year: i32) -> Result<Vec<u8>, FormsError> {
82    let map = Form8949Map::for_year(year)?;
83    let cap = map.rows_per_page;
84    let (st, lt) = fill8949::split_parts(rows);
85    let n_pages = div_ceil(st.len(), cap).max(div_ceil(lt.len(), cap)).max(1);
86
87    if n_pages == 1 {
88        let short = fill8949::part_data(&st)?;
89        let long = fill8949::part_data(&lt)?;
90        return fill8949::fill_8949_parts(&short, &long, &map);
91    }
92
93    let mut copies = Vec::with_capacity(n_pages);
94    for k in 0..n_pages {
95        let st_chunk: Vec<&Form8949Row> = st.iter().skip(k * cap).take(cap).copied().collect();
96        let lt_chunk: Vec<&Form8949Row> = lt.iter().skip(k * cap).take(cap).copied().collect();
97        let short = fill8949::part_data(&st_chunk)?;
98        let long = fill8949::part_data(&lt_chunk)?;
99        // Each copy is filled on ORIGINAL names and geometry-verified here (fails closed).
100        copies.push(fill8949::fill_8949_parts(&short, &long, &map)?);
101    }
102    overflow::merge_copies(&copies)
103}
104
105fn div_ceil(n: usize, d: usize) -> usize {
106    n.div_ceil(d)
107}
108
109/// Stamp a diagonal `DRAFT — ESTIMATE, NOT FOR FILING` watermark on every page of a filled form.
110/// Applied by the CLI when the ledger is pseudo-reconciled (an estimate). The overlay carries its own
111/// embedded standard font resource, orthogonal to `/NeedAppearances`.
112pub fn stamp_draft_watermark(pdf_bytes: &[u8]) -> Result<Vec<u8>, FormsError> {
113    watermark::stamp_draft(pdf_bytes)
114}
115
116/// Fill **Schedule D** for `year` from the part totals and return the PDF bytes.
117pub fn fill_schedule_d(totals: &ScheduleDTotals, year: i32) -> Result<Vec<u8>, FormsError> {
118    let map = ScheduleDMap::for_year(year)?;
119    schedule_d::fill_schedule_d_totals(totals, &map)
120}
121
122/// Fill **Schedule SE** (Form 1040) for `year` from the computed §1401 `SeTaxResult`, the filer's
123/// Form W-2 Social Security wages (line 8a), and the year's Social Security wage base (line 7).
124/// Returns `Ok(None)` when net SE earnings are **below the $400 floor** (no SE tax owed — the form is
125/// not written). Line 12 = SS + regular Medicare only (the 0.9% Additional Medicare Tax is a Form 8959
126/// item, not on Schedule SE); when `se.addl > 0` the caller prints a Form 8959 advisory.
127pub fn fill_schedule_se(
128    se: &btctax_core::SeTaxResult,
129    w2_ss_wages: Usd,
130    ss_wage_base: Usd,
131    year: i32,
132) -> Result<Option<Vec<u8>>, FormsError> {
133    let map = ScheduleSeMap::for_year(year)?;
134    schedule_se::fill_schedule_se_with_map(se, w2_ss_wages, ss_wage_base, &map)
135}
136
137/// Fill **Form 8959** (Additional Medicare Tax) for `year` from the core-derived line chain
138/// (`btctax_core::tax::other_taxes::form_8959_lines`). Returns `Ok(None)` when the form is not
139/// required — line 18 (the tax) AND line 24 (the withholding reconciliation) are both zero.
140///
141/// Line 24 is not redundant with line 18: an employer withholds the 0.9% on ITS OWN wages over
142/// $200,000 with no knowledge of a spouse or a second job, so a taxpayer who owes NO Additional
143/// Medicare Tax can still have had some withheld — and that excess is a credit on 1040 line 25c.
144/// Skipping the form on line 18 alone would silently forfeit it.
145pub fn fill_form_8959(
146    lines: &btctax_core::tax::other_taxes::Form8959Lines,
147    header: &btctax_core::tax::packet::ReturnHeader,
148    year: i32,
149) -> Result<Option<Vec<u8>>, FormsError> {
150    let map = Form8959Map::for_year(year)?;
151    form8959::fill_form_8959_with_map(lines, header, &map)
152}
153
154/// Fill **Form 8960** (Net Investment Income Tax, §1411) for `year` from the core-derived line chain
155/// (`btctax_core::tax::other_taxes::form_8960_lines`, which returns `None` when no NIIT is owed —
156/// there is then no form to file).
157pub fn fill_form_8960(
158    lines: &btctax_core::tax::other_taxes::Form8960Lines,
159    header: &btctax_core::tax::packet::ReturnHeader,
160    year: i32,
161) -> Result<Vec<u8>, FormsError> {
162    let map = Form8960Map::for_year(year)?;
163    form8960::fill_form_8960_with_map(lines, header, &map)
164}
165
166/// Fill **Form 8995** (QBI deduction, simplified) for `year` from the core-derived line chain
167/// (`btctax_core::tax::qbi::form_8995_lines`, which returns `None` when there is no QBI).
168///
169/// FAILS CLOSED if a parenthesized cell (line 7/16/17) carries a negative value: the form pre-prints
170/// the parentheses, so a negative would render as a POSITIVE number on the filed return.
171pub fn fill_form_8995(
172    lines: &btctax_core::tax::qbi::Form8995Lines,
173    header: &btctax_core::tax::packet::ReturnHeader,
174    year: i32,
175) -> Result<Vec<u8>, FormsError> {
176    let map = Form8995Map::for_year(year)?;
177    form8995::fill_form_8995_with_map(lines, header, &map)
178}
179
180/// Fill **Schedule 1** (Additional Income and Adjustments to Income) for `year` from the core-derived
181/// printed chain (`btctax_core::tax::printed::schedule_1_lines`, which returns `None` when there is
182/// neither additional income nor an adjustment — the schedule is then not filed).
183pub fn fill_schedule_1(
184    lines: &btctax_core::tax::printed::Schedule1Lines,
185    header: &btctax_core::tax::packet::ReturnHeader,
186    year: i32,
187) -> Result<Vec<u8>, FormsError> {
188    let map = Schedule1Map::for_year(year)?;
189    schedule23::fill_schedule_1_with_map(lines, header, &map)
190}
191
192/// Fill **Schedule 2** (Additional Taxes) for `year` from the core-derived printed chain
193/// (`btctax_core::tax::printed::schedule_2_lines`, which returns `None` when there are no other
194/// taxes to report — the schedule is then not filed).
195pub fn fill_schedule_2(
196    lines: &btctax_core::tax::printed::Schedule2Lines,
197    header: &btctax_core::tax::packet::ReturnHeader,
198    year: i32,
199) -> Result<Vec<u8>, FormsError> {
200    let map = Schedule2Map::for_year(year)?;
201    schedule23::fill_schedule_2_with_map(lines, header, &map)
202}
203
204/// Fill **Schedule 3** (Additional Credits and Payments) for `year` from the core-derived printed
205/// chain (`btctax_core::tax::printed::schedule_3_lines`, which returns `None` when there is neither a
206/// foreign tax credit nor an excess-Social-Security credit).
207pub fn fill_schedule_3(
208    lines: &btctax_core::tax::printed::Schedule3Lines,
209    header: &btctax_core::tax::packet::ReturnHeader,
210    year: i32,
211) -> Result<Vec<u8>, FormsError> {
212    let map = Schedule3Map::for_year(year)?;
213    schedule23::fill_schedule_3_with_map(lines, header, &map)
214}
215
216/// Fill **Schedule A** (Itemized Deductions) for `year` from the core-derived printed chain
217/// (`btctax_core::tax::printed::schedule_a_lines`, which returns `None` unless the return actually
218/// itemizes — Schedule A is computed even when the standard deduction wins, but only FILED when it is
219/// the deduction claimed).
220pub fn fill_schedule_a(
221    lines: &btctax_core::tax::printed::ScheduleALines,
222    header: &btctax_core::tax::packet::ReturnHeader,
223    year: i32,
224) -> Result<Vec<u8>, FormsError> {
225    let map = ScheduleAMap::for_year(year)?;
226    schedule_a::fill_schedule_a_with_map(lines, header, &map)
227}
228
229/// Fill the **FULL-RETURN Form 1040** for `year` from the core-derived printed chain
230/// (`btctax_core::tax::printed::form_1040_lines`) — every line, not just the capital-gain cluster.
231///
232/// This is NOT [`fill_form_1040_capgains`], which writes only line 7 + the Digital-Asset question for
233/// the crypto-slice export. That one stays: for a year with no `ReturnInputs` it is what the filer
234/// wants.
235pub fn fill_form_1040_full(
236    lines: &btctax_core::tax::printed::Form1040Lines,
237    header: &btctax_core::tax::packet::ReturnHeader,
238    status: btctax_core::tax::types::FilingStatus,
239    year: i32,
240) -> Result<Vec<u8>, FormsError> {
241    let map = Form1040Map::for_year(year)?;
242    form1040_full::fill_form_1040_full_with_map(lines, header, status, &map)
243}
244
245/// Fill the **FULL-RETURN Schedule D** for `year` from the core-derived printed chain
246/// (`btctax_core::tax::printed::schedule_d_lines`), including Part III's SPEC §7.2 routing.
247///
248/// This is NOT [`fill_schedule_d`], which is the **crypto-slice** fill: that one writes only lines
249/// 3/7/10/15/16 from the ledger totals, with no line 13 (1099-DIV box-2a capital-gain distributions)
250/// and no lines 6/14 (capital-loss carryovers). For a crypto-only year that is complete and correct.
251/// For a full return it would be a complete-LOOKING form with income missing.
252///
253/// FAILS CLOSED if a parenthesized cell (line 6/14/21) carries a negative: the form pre-prints the
254/// parentheses, so a negative renders as a POSITIVE number — turning a capital loss into a gain.
255pub fn fill_schedule_d_full(
256    lines: &btctax_core::tax::printed::ScheduleDLines,
257    header: &btctax_core::tax::packet::ReturnHeader,
258    year: i32,
259) -> Result<Vec<u8>, FormsError> {
260    let map = ScheduleDMap::for_year(year)?;
261    schedule_d_full::fill_schedule_d_full_with_map(lines, header, &map)
262}
263
264/// Fill the **full-return Form 8283** for `year` — whole-dollar rows plus the FILER's identity block
265/// (which the crypto slice never writes). `Ok(None)` when there are no donation rows.
266pub fn fill_form_8283_full(
267    printed: &btctax_core::tax::printed::Printed8283Rows,
268    header: &btctax_core::tax::packet::ReturnHeader,
269    year: i32,
270) -> Result<Option<Vec<u8>>, FormsError> {
271    let map = Form8283Map::for_year(year)?;
272    form8283::fill_form_8283_full(printed, header, &map)
273}
274
275/// Fill the **full-return Form 8949** for `year` from the core-derived printed chain
276/// (`btctax_core::tax::printed::form_8949_printed`). Whole dollars, and column (h) is DERIVED from the
277/// printed (d) − (e). Schedule D lines 3/10 ARE this form's printed column totals.
278pub fn fill_8949_full(
279    printed: &btctax_core::tax::printed::Printed8949,
280    header: &btctax_core::tax::packet::ReturnHeader,
281    year: i32,
282) -> Result<Vec<u8>, FormsError> {
283    let map = Form8949Map::for_year(year)?;
284    fill8949_full::fill_8949_full_with_map(printed, header, &map)
285}
286
287/// Fill the **full-return Schedule SE** for `year` from the core-derived printed chain
288/// (`btctax_core::tax::printed::schedule_se_lines`). Whole dollars — the crypto slice's
289/// `fill_schedule_se` keeps its exact-cents rendering and is untouched.
290pub fn fill_schedule_se_full(
291    lines: &btctax_core::tax::printed::ScheduleSeLines,
292    header: &btctax_core::tax::packet::ReturnHeader,
293    year: i32,
294) -> Result<Vec<u8>, FormsError> {
295    let map = ScheduleSeMap::for_year(year)?;
296    schedule_se_full::fill_schedule_se_full_with_map(lines, header, &map)
297}
298
299/// Fill **Schedule B** (Interest and Ordinary Dividends) for `year` from the core-derived printed
300/// chain (`btctax_core::tax::printed::schedule_b_lines`, which returns `None` when Schedule B is not
301/// required — interest and dividends both at or under $1,500 and no declared foreign account).
302///
303/// REFUSES when there are more payers than the form has rows (14 interest / 15 dividend). Truncating
304/// the list would leave a form whose printed rows do not add up to its own total.
305pub fn fill_schedule_b(
306    lines: &btctax_core::tax::printed::ScheduleBLines,
307    header: &btctax_core::tax::packet::ReturnHeader,
308    year: i32,
309) -> Result<Vec<u8>, FormsError> {
310    let map = ScheduleBMap::for_year(year)?;
311    schedule_b::fill_schedule_b_with_map(lines, header, &map)
312}
313
314/// Fill **Schedule C** (Profit or Loss From Business) for `year` from the core-derived printed chain
315/// (`btctax_core::tax::printed::schedule_c_lines`, which returns `None` when there is no crypto trade
316/// or business).
317pub fn fill_schedule_c(
318    lines: &btctax_core::tax::printed::ScheduleCLines,
319    header: &btctax_core::tax::packet::ReturnHeader,
320    year: i32,
321) -> Result<Vec<u8>, FormsError> {
322    let map = ScheduleCMap::for_year(year)?;
323    schedule_c::fill_schedule_c_with_map(lines, header, &map)
324}
325
326/// Fill **Form 8283** (Noncash Charitable Contributions, Rev. 12-2025) for `year` from the projected
327/// donation rows + `DonationDetails`. Returns `Ok(None)` when there are no donations in the year.
328/// Fills the donee/appraiser IDENTITY + per-row property data (and, for Section B, checks the "k
329/// Digital assets" property-type box); leaves BLANK every OTHER party's declaration/signature (a
330/// LOUD partial-scope notice is the caller's). More rows than a section holds (4 in Section A / 3 in
331/// Section B) overflow onto additional form copies via [`overflow::merge_copies`].
332pub fn fill_form_8283(
333    rows: &[btctax_core::Form8283Row],
334    year: i32,
335) -> Result<Option<Vec<u8>>, FormsError> {
336    let map = Form8283Map::for_year(year)?;
337    form8283::fill_form_8283(rows, &map)
338}
339
340/// Fill the capital-gains cells of **Form 1040** for `year`: line 7a (only when Schedule D is ACTIVE
341/// and line 16 ≥ 0; active-and-zero → "-0-") and the Digital-Asset Yes/No question (YES iff there is
342/// btctax-evidenced qualifying activity). Returns `Ok(None)` — **skip the whole 1040** — when there is
343/// no reportable activity (the DA answer would be blank and there is no 7a value). 7b checkboxes are
344/// left untouched; a NET LOSS leaves 7a blank (the §1211 line-21 cap is the filer's). A partial-scope
345/// notice enumerating exactly what was filled is the caller's.
346pub fn fill_form_1040_capgains(
347    inputs: &Form1040Inputs,
348    year: i32,
349) -> Result<Option<Form1040Fill>, FormsError> {
350    let map = Form1040Map::for_year(year)?;
351    form1040::fill_form_1040_capgains(inputs, &map)
352}
353
354/// **[I5]** How many rows might belong on a SEPARATE 1099-DA-reported Form 8949 (Box G/H/J/K) — i.e.
355/// disposals on an exchange that may have issued broker basis reporting. SP1 files EVERY Bitcoin row
356/// under Box I/L and says so; a non-zero count is a loud advisory, not a refusal.
357pub fn rows_possibly_broker_reported(rows: &[Form8949Row]) -> usize {
358    rows.iter().filter(|r| r.box_needs_review).count()
359}
360
361// ── Internals exposed for the KATs (fault injection needs a corruptible map + the verifier). ──────
362#[doc(hidden)]
363pub use packet::{fill_full_return, NamedForm};
364
365pub mod testonly {
366    pub use crate::cells::fmt_money_pair;
367    pub use crate::fill8949::{fill_8949_parts, part_data, pdf_has_xfa, split_parts, PartData};
368    pub use crate::fill8949_full::fill_8949_full_with_map;
369    pub use crate::form1040::{fill_form_1040_capgains as fill_1040_with_map, Form1040Fill};
370    pub use crate::form1040_full::fill_form_1040_full_with_map;
371    pub use crate::form8283::fill_form_8283 as fill_8283_with_map;
372    pub use crate::form8959::fill_form_8959_with_map;
373    pub use crate::form8960::fill_form_8960_with_map;
374    pub use crate::form8995::fill_form_8995_with_map;
375    // The committed map TOML, for the line-keyed inverse transcriber (`extract_lines`). Downstream
376    // read-back tests need the map itself, not just its parsed struct.
377    pub use crate::map::{
378        F1040_MAP_2024, F8283_MAP_2024, F8949_MAP_2024, F8959_MAP_2024, F8960_MAP_2024,
379        F8995_MAP_2024, SCHEDULE_1_MAP_2024, SCHEDULE_2_MAP_2024, SCHEDULE_3_MAP_2024,
380        SCHEDULE_A_MAP_2024, SCHEDULE_B_MAP_2024, SCHEDULE_C_MAP_2024, SCHEDULE_D_MAP_2024,
381        SCHEDULE_SE_MAP_2024,
382    };
383    pub use crate::map::{
384        AmountCols, Form1040Map, Form8283Map, Form8949Map, Form8959Map, Form8960Map, Form8995Map,
385        MoneyCell, MoneyPair, PartMap, Schedule1Map, Schedule2Map, Schedule3Map, ScheduleAMap,
386        ScheduleBMap, ScheduleCMap, ScheduleDMap, ScheduleSeMap,
387    };
388    pub use crate::pdf::{
389        button_on_states, checkbox_on, collect_fields, index, load, text_value, Field,
390        F1040_PDF_2017, F1040_PDF_2024, F1040_PDF_2025, F8283_PDF_2017, F8283_PDF_2024,
391        F8283_PDF_2025, F8949_PDF_2017, F8949_PDF_2024, F8949_PDF_2025, F8959_PDF_2024,
392        SCHEDULE_D_PDF_2017, SCHEDULE_D_PDF_2024, SCHEDULE_D_PDF_2025, SCHEDULE_SE_PDF_2017,
393        SCHEDULE_SE_PDF_2024, SCHEDULE_SE_PDF_2025,
394    };
395    pub use crate::schedule23::{
396        fill_schedule_1_with_map, fill_schedule_2_with_map, fill_schedule_3_with_map,
397    };
398    pub use crate::schedule_a::fill_schedule_a_with_map;
399    pub use crate::schedule_b::fill_schedule_b_with_map;
400    pub use crate::schedule_c::fill_schedule_c_with_map;
401    pub use crate::schedule_d::fill_schedule_d_totals;
402    pub use crate::schedule_d_full::fill_schedule_d_full_with_map;
403    pub use crate::schedule_se::fill_schedule_se_with_map;
404    pub use crate::schedule_se_full::fill_schedule_se_full_with_map;
405    pub use crate::transcribe::extract_lines;
406    pub use crate::verify::{
407        no_unmapped_filled, topmost_yes_no_pair, verify_8949, verify_flat, FlatPlacement, Geo,
408        Placement,
409    };
410}