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}