Skip to main content

btctax_forms/
form1040.rs

1//! Form 1040 capital-gains cells ONLY: line 7a + the Digital-Asset Yes/No question. Read back through
2//! the SP2 flat oracle (amount-column x-cluster) + the map-independent same-y `/Btn` pair predicate.
3//!
4//! **[★ R0-C2 + I★1] Line 7a** (renumbered in 2025; a 7b checkbox pair is new). btctax vouches for
5//! exactly two cells here, so:
6//! - **Fill 7a ONLY when Schedule D is ACTIVE (there are capital disposals) AND line 16 ≥ 0.** A gain
7//!   → the line-16 amount; **active-and-netted-to-zero → the "-0-" literal**.
8//! - **Schedule D INACTIVE** (income-only / donation-only year; the DA answer may be YES but there are
9//!   no capital disposals) → **7a BLANK**. Stamping "-0-" against a blank Schedule D line 16 would be
10//!   an unearned zero-capital-gains claim.
11//! - **NET LOSS** → **7a BLANK** + a loud §1211 notice (the $3,000/$1,500-MFS cap on Schedule D line
12//!   21 is the filer's; SP1 scoped out line 21).
13//! - **7b checkboxes stay untouched.**
14//!
15//! **[★ R0-C4] Digital-Asset question = YES only with btctax-evidenced qualifying activity** (any
16//! disposal ∨ any income_recognized ∨ any gift/donate removal). Otherwise **skip the whole 1040** (No
17//! is never filled — btctax cannot know the filer's full digital-asset universe).
18
19use crate::cells::{push_literal, push_money};
20use crate::error::FormsError;
21use crate::map::Form1040Map;
22use crate::pdf;
23use crate::verify::{topmost_yes_no_pair, verify_flat, FlatPlacement};
24use btctax_core::Usd;
25
26/// Hand-pinned Form 1040 capital-gain amount column-x cluster, **per form revision**. 2024/2025 line
27/// 7/7a sits at x ≈ [504,576] (single field); the 2017 line-13 dollars field sits at cx ≈ 518 and its
28/// cluster must EXCLUDE the adjacent narrow cents widget (cx ≈ 565) so a dollars↔cents swap fails
29/// closed. Geometry ORACLE — code-side, never from the (distrusted) map.
30const F1040_COL_AMOUNT: usize = 0;
31const F1040_CLUSTERS_UNIFIED: &[(f32, f32)] = &[(504.0, 576.0)];
32const F1040_CLUSTERS_2017: &[(f32, f32)] = &[(482.0, 555.0)];
33
34fn f1040_clusters(year: i32) -> &'static [(f32, f32)] {
35    match year {
36        2017 => F1040_CLUSTERS_2017,
37        _ => F1040_CLUSTERS_UNIFIED,
38    }
39}
40
41/// The btctax-evidenced signals that drive the two Form 1040 cells.
42#[derive(Debug, Clone, Copy)]
43pub struct Form1040Inputs {
44    /// Digital-Asset question = YES iff there is any btctax-evidenced qualifying activity: any
45    /// `form_8949` disposal ∨ any `income_recognized` ∨ any Gift/Donate removal. When `false` there is
46    /// no reportable activity and the whole 1040 is skipped.
47    pub da_yes: bool,
48    /// Schedule D is ACTIVE — there are capital disposals (some ST or LT part has activity).
49    pub schedule_d_active: bool,
50    /// Schedule D line 16 = ST gain + LT gain (raw, pre-netting). Only consulted when active.
51    pub schedule_d_line16: Usd,
52}
53
54/// The result of a Form 1040 cap-gains fill: the bytes + what was actually written (drives the CLI's
55/// partial-scope + loss notices).
56#[derive(Debug, Clone)]
57pub struct Form1040Fill {
58    /// The serialized PDF bytes.
59    pub pdf: Vec<u8>,
60    /// Whether line 7a received a value (a gain amount or the "-0-" literal).
61    pub filled_7a: bool,
62    /// Active-and-netted-to-zero → line 7a is the "-0-" literal.
63    pub active_zero: bool,
64    /// Net loss → line 7a left BLANK; the caller prints the §1211 line-21 notice.
65    pub loss: bool,
66}
67
68/// Fill the Form 1040 capital-gains cells. Returns `Ok(None)` — skip the whole 1040 — when there is no
69/// reportable activity (`da_yes == false`). Otherwise the DA question is answered YES and line 7a is
70/// filled per the active/line-16 rules. Read back through the geometric verifier (fails closed).
71pub fn fill_form_1040_capgains(
72    inputs: &Form1040Inputs,
73    map: &Form1040Map,
74) -> Result<Option<Form1040Fill>, FormsError> {
75    // Produce/skip decision, per revision:
76    //  • DA years (2024/2025): [R0-C4] produce iff there is reportable digital-asset activity (never
77    //    fill "No" — btctax cannot vouch for the filer's full digital-asset universe).
78    //  • 2017 (no DA question): produce iff there is reportable capital activity that yields a line-13
79    //    entry — a gain or an active-and-netted-to-zero "-0-". Income-only / net-loss years ⇒ skip.
80    if map.da_present {
81        if !inputs.da_yes {
82            return Ok(None);
83        }
84    } else if !(inputs.schedule_d_active && inputs.schedule_d_line16 >= Usd::ZERO) {
85        return Ok(None);
86    }
87
88    let mut writes: Vec<(String, pdf::FieldValue)> = Vec::new();
89    let mut placements: Vec<FlatPlacement> = Vec::new();
90
91    // Digital-Asset question = YES — only on years whose 1040 carries it (left member of the same-y
92    // {/1,/2} pair, on-state /1). The 2017 form has none, so nothing is written.
93    if map.da_present {
94        let da_yes = map.da_yes.as_ref().ok_or_else(|| {
95            FormsError::Structure("1040 map has da_present=true but no da_yes field".into())
96        })?;
97        writes.push((
98            da_yes.field.clone(),
99            pdf::FieldValue::Check {
100                on: da_yes.on.clone(),
101            },
102        ));
103        placements.push(FlatPlacement::check(da_yes.field.clone(), 0));
104    }
105
106    // Capital-gain line (7a in 2025 / 7 in 2024 / **13 in 2017**) — only when Schedule D is ACTIVE and
107    // line 16 ≥ 0. The cell is single (2024/2025) or a dollars+cents pair (2017), handled uniformly.
108    let mut filled_7a = false;
109    let mut active_zero = false;
110    let mut loss = false;
111    if inputs.schedule_d_active {
112        if inputs.schedule_d_line16 < Usd::ZERO {
113            loss = true; // net loss → line BLANK + notice (§1211 line-21 cap is the filer's).
114        } else if inputs.schedule_d_line16.is_zero() {
115            active_zero = true; // active-and-netted-to-zero → the "-0-" literal.
116            push_literal(
117                &mut writes,
118                &mut placements,
119                &map.line7a,
120                "-0-",
121                F1040_COL_AMOUNT,
122            );
123            filled_7a = true;
124        } else {
125            push_money(
126                &mut writes,
127                &mut placements,
128                &map.line7a,
129                inputs.schedule_d_line16,
130                F1040_COL_AMOUNT,
131                None,
132            );
133            filled_7a = true;
134        }
135    }
136    // else: Schedule D INACTIVE (income-only / donation-only DA year) → line BLANK even though DA = YES.
137
138    let mut doc = pdf::load(pdf::f1040_pdf(map.year)?)?;
139    let index = pdf::index(&pdf::collect_fields(&doc)?);
140    pdf::drop_xfa_and_set_needappearances(&mut doc)?;
141    pdf::apply_writes(&mut doc, &index, &writes)?;
142    pdf::strip_nondeterminism(&mut doc);
143    let bytes = pdf::save(&mut doc)?;
144
145    // Read back the SERIALIZED output.
146    let check = pdf::load(&bytes)?;
147    let fields = pdf::collect_fields(&check)?;
148    verify_flat(&check, &fields, &placements, f1040_clusters(map.year))?;
149    // Map-independent DA-question guard (only on years whose 1040 HAS the question): the map's Yes/No
150    // must BE the left/right members of the top-most horizontally-ADJACENT same-y {/1,/2} pair — a
151    // Yes/No swap in the map fails closed here. The no-DA 2017 form skips it.
152    if map.da_present {
153        let da_yes = map.da_yes.as_ref().expect("checked above");
154        let da_no = map.da_no.as_ref().ok_or_else(|| {
155            FormsError::Structure("1040 map has da_present=true but no da_no field".into())
156        })?;
157        let (yes_fqn, no_fqn) = topmost_yes_no_pair(&check, &fields, 0)?;
158        if yes_fqn != da_yes.field {
159            return Err(FormsError::Geometry(format!(
160                "1040 DA 'Yes' map field {:?} is not the LEFT member of the top-most adjacent {{/1,/2}} pair ({yes_fqn:?})",
161                da_yes.field
162            )));
163        }
164        if no_fqn != da_no.field {
165            return Err(FormsError::Geometry(format!(
166                "1040 DA 'No' map field {:?} is not the right member of the top-most adjacent {{/1,/2}} pair ({no_fqn:?})",
167                da_no.field
168            )));
169        }
170    }
171
172    Ok(Some(Form1040Fill {
173        pdf: bytes,
174        filled_7a,
175        active_zero,
176        loss,
177    }))
178}