btctax-forms 0.3.0

Fill the official IRS fillable PDFs (Form 8949 + Schedule D) from btctax's computed tax data — offline, deterministic, geometry-verified (part of btctax).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
//! Committed, per-(form, year) field maps: **logical cell → fully-qualified PDF field name**.
//!
//! The maps are DATA (TOML committed next to the bundled PDFs), not code — "adding a year" is a
//! `forms/<year>/` directory (PDF + maps), never a code change. Keys are the fully-qualified,
//! bracketed AcroForm names (`topmostSubform[0].Page1[0].Table_Line1_Part1[0].Row1[0].f1_03[0]`).
//!
//! Nothing here is trusted blindly: the geometric read-back ([`crate::verify`]) re-derives the
//! column/row bands from the bundled PDF's own widget `/Rect`s and would flag any mis-labeled cell,
//! and `map_2025_matches_bundled_pdf_fieldset` asserts every name here exists in the PDF.

use crate::error::FormsError;
use serde::Deserialize;

/// The TY2025 Form 8949 map (embedded at compile time).
pub const F8949_MAP_2025: &str = include_str!("../forms/2025/f8949.map.toml");
/// The TY2025 Schedule D map (embedded at compile time).
pub const SCHEDULE_D_MAP_2025: &str = include_str!("../forms/2025/schedule_d.map.toml");
/// The TY2025 Schedule SE map (embedded at compile time).
pub const SCHEDULE_SE_MAP_2025: &str = include_str!("../forms/2025/schedule_se.map.toml");
/// The TY2025 Form 8283 map (embedded at compile time).
pub const F8283_MAP_2025: &str = include_str!("../forms/2025/f8283.map.toml");
/// The TY2025 Form 1040 map (embedded at compile time).
pub const F1040_MAP_2025: &str = include_str!("../forms/2025/f1040.map.toml");

/// The TY2024 Form 8949 map (embedded at compile time).
pub const F8949_MAP_2024: &str = include_str!("../forms/2024/f8949.map.toml");
/// The TY2024 Schedule D map (embedded at compile time).
pub const SCHEDULE_D_MAP_2024: &str = include_str!("../forms/2024/schedule_d.map.toml");
/// The TY2024 Schedule SE map (embedded at compile time).
pub const SCHEDULE_SE_MAP_2024: &str = include_str!("../forms/2024/schedule_se.map.toml");
/// The TY2024 Form 8283 map (Rev. 12-2023, embedded at compile time).
pub const F8283_MAP_2024: &str = include_str!("../forms/2024/f8283.map.toml");
/// The TY2024 Form 1040 map (embedded at compile time).
pub const F1040_MAP_2024: &str = include_str!("../forms/2024/f1040.map.toml");

/// The TY2017 Form 8949 map (embedded at compile time).
pub const F8949_MAP_2017: &str = include_str!("../forms/2017/f8949.map.toml");
/// The TY2017 Schedule D map (embedded at compile time).
pub const SCHEDULE_D_MAP_2017: &str = include_str!("../forms/2017/schedule_d.map.toml");
/// The TY2017 Schedule SE map (OLD short+long form; btctax fills §B long — embedded at compile time).
pub const SCHEDULE_SE_MAP_2017: &str = include_str!("../forms/2017/schedule_se.map.toml");
/// The TY2017 Form 8283 map (Rev. 12-2014, "j Other" — embedded at compile time).
pub const F8283_MAP_2017: &str = include_str!("../forms/2017/f8283.map.toml");
/// The TY2017 Form 1040 map (line 13, no DA question — embedded at compile time).
pub const F1040_MAP_2017: &str = include_str!("../forms/2017/f1040.map.toml");

/// The 4 monetary "amount" columns of a Form 8949 / Schedule D totals row: (d) proceeds, (e) cost,
/// (g) adjustment, (h) gain. Column (f) — the code column — has no total (a spacer), so it is absent.
#[derive(Debug, Clone, Deserialize)]
pub struct AmountCols {
    /// Column (d) — proceeds.
    pub proceeds_d: String,
    /// Column (e) — cost basis.
    pub cost_e: String,
    /// Column (g) — adjustment amount.
    pub adj_g: String,
    /// Column (h) — gain/loss.
    pub gain_h: String,
}

/// One Form 8949 part (Part I short-term on page 0, Part II long-term on page 1).
#[derive(Debug, Clone, Deserialize)]
pub struct PartMap {
    /// `"short"` (Part I) or `"long"` (Part II).
    pub term: String,
    /// 0-based page index of this part within the bundled 2-page PDF.
    pub page: usize,
    /// The digital-asset box checkbox field — **Box I** (ST) / **Box L** (LT), NOT C/F.
    pub box_field: String,
    /// The checkbox on-state (a PDF name without the leading `/`), e.g. `"6"`.
    pub box_on: String,
    /// The line-2 per-part totals row (d,e,g,h).
    pub totals: AmountCols,
    /// The 11 data rows; each row is the 8 column field names in order a,b,c,d,e,f,g,h.
    pub rows: Vec<Vec<String>>,
}

/// The full Form 8949 field map for one tax year.
#[derive(Debug, Clone, Deserialize)]
pub struct Form8949Map {
    /// `"f8949"`.
    pub form: String,
    /// Tax year (e.g. 2025).
    pub year: i32,
    /// Rows per part per page — **map data**, not a hard-coded constant (a new form revision that
    /// changes the grid is a data-only edit).
    pub rows_per_page: usize,
    /// The data-grid subform token used to re-derive the geometry bands — **per-year map config**,
    /// not a const (2024 = `Table_Line1`, 2025 = `Table_Line1_Part`; the row fqns differ by year).
    pub table_token: String,
    /// Part I then Part II.
    pub parts: Vec<PartMap>,
}

impl Form8949Map {
    /// Parse the committed TOML.
    pub fn parse(toml_src: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(toml_src)
    }

    /// The TY2025 map.
    pub fn ty2025() -> Self {
        Self::parse(F8949_MAP_2025).expect("bundled f8949 2025 map parses")
    }

    /// The TY2024 map.
    pub fn ty2024() -> Self {
        Self::parse(F8949_MAP_2024).expect("bundled f8949 2024 map parses")
    }

    /// The TY2017 map (pre-1099-DA: Box C/F, `/3`; field-identical grid to 2024).
    pub fn ty2017() -> Self {
        Self::parse(F8949_MAP_2017).expect("bundled f8949 2017 map parses")
    }

    /// The map for a supported tax year.
    pub fn for_year(year: i32) -> Result<Self, FormsError> {
        match year {
            2017 => Ok(Self::ty2017()),
            2024 => Ok(Self::ty2024()),
            2025 => Ok(Self::ty2025()),
            _ => Err(FormsError::UnsupportedYear(year)),
        }
    }

    /// The part with the given term, if present.
    pub fn part(&self, term: &str) -> Option<&PartMap> {
        self.parts.iter().find(|p| p.term == term)
    }
}

/// A checkbox choice (field + on-state) — used for the Schedule D QOF Yes/No answer and the Form 1040
/// Digital-Asset Yes/No question.
#[derive(Debug, Clone, Deserialize)]
pub struct CheckChoice {
    /// The checkbox field name.
    pub field: String,
    /// On-state PDF name (without leading `/`).
    pub on: String,
}

/// A dollars-field + cents-field PAIR (the 2017 Schedule SE / Form 1040 / Form 8283 split every money
/// amount into a whole-dollars field and a 2-digit cents field). The geometric oracle treats the pair
/// as ONE logical cell **at the dollars-field geometry** (the cents field rides along as an authorized
/// but geometry-exempt write). Because both fields descend from the same AcroForm root, `merge_copies`
/// (which renames only the root `/T`) rewrites BOTH names as a unit — so overflow is safe.
#[derive(Debug, Clone, Deserialize)]
pub struct MoneyPair {
    /// The whole-dollars field (the one the geometry oracle checks — column-x + row/descent).
    pub dollars_field: String,
    /// The 2-digit cents field (an authorized write; NOT independently geometry-checked).
    pub cents_field: String,
}

/// A monetary cell: a single field carrying the whole formatted amount (2024/2025), or a
/// dollars+cents [`MoneyPair`] (the 2017 forms). Deserializes untagged: a TOML **string** →
/// [`MoneyCell::Single`]; a TOML **inline table** `{ dollars_field, cents_field }` →
/// [`MoneyCell::Pair`].
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum MoneyCell {
    /// A single field holding the whole formatted amount.
    Single(String),
    /// A dollars-field + cents-field pair.
    Pair(MoneyPair),
}

impl MoneyCell {
    /// Every PDF field this cell targets (1 for a single, 2 for a pair) — for coverage guards.
    pub fn fields(&self) -> Vec<&str> {
        match self {
            MoneyCell::Single(f) => vec![f.as_str()],
            MoneyCell::Pair(p) => vec![p.dollars_field.as_str(), p.cents_field.as_str()],
        }
    }
}

/// A per-year default: the Digital-Asset question is present unless a year's map says otherwise.
fn default_da_present() -> bool {
    true
}

/// The Form 1040 capital-gains field map for one tax year: the capital-gain amount cell (line 7a in
/// 2025 / line 7 in 2024 / **line 13** in 2017) + the Digital-Asset question (absent in 2017).
#[derive(Debug, Clone, Deserialize)]
pub struct Form1040Map {
    /// `"f1040"`.
    pub form: String,
    /// Tax year.
    pub year: i32,
    /// The capital-gain amount cell (line 7a for 2025, line 7 for 2024, **line 13 for 2017**). A
    /// single field on 2024/2025; a dollars+cents [`MoneyPair`] on the 2017 form.
    pub line7a: MoneyCell,
    /// Whether this year's 1040 carries the Digital-Asset question — **per-year scaffolding**. When
    /// `true` (2024/2025) the fill answers it "Yes" and runs the map-independent adjacency guard;
    /// **2017 sets it `false`** (no DA question — the map omits `da_yes`/`da_no` and the fill produces
    /// the 1040 iff there is reportable capital activity).
    #[serde(default = "default_da_present")]
    pub da_present: bool,
    /// Digital-Asset question "Yes" (LEFT member of the adjacent pair, on-state `/1`). `None` when the
    /// year's 1040 has no DA question (2017).
    #[serde(default)]
    pub da_yes: Option<CheckChoice>,
    /// Digital-Asset question "No" (right member, on-state `/2`) — never checked by btctax. `None`
    /// when the year's 1040 has no DA question (2017).
    #[serde(default)]
    pub da_no: Option<CheckChoice>,
}

impl Form1040Map {
    /// Parse the committed TOML.
    pub fn parse(toml_src: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(toml_src)
    }

    /// The TY2025 map.
    pub fn ty2025() -> Self {
        Self::parse(F1040_MAP_2025).expect("bundled f1040 2025 map parses")
    }

    /// The TY2024 map.
    pub fn ty2024() -> Self {
        Self::parse(F1040_MAP_2024).expect("bundled f1040 2024 map parses")
    }

    /// The TY2017 map (capital gain on line 13; NO Digital-Asset question).
    pub fn ty2017() -> Self {
        Self::parse(F1040_MAP_2017).expect("bundled f1040 2017 map parses")
    }

    /// The map for a supported tax year.
    pub fn for_year(year: i32) -> Result<Self, FormsError> {
        match year {
            2017 => Ok(Self::ty2017()),
            2024 => Ok(Self::ty2024()),
            2025 => Ok(Self::ty2025()),
            _ => Err(FormsError::UnsupportedYear(year)),
        }
    }
}

/// One Form 8283 **Section A** row (Donated Property of $5,000 or Less): the 8 filled columns.
#[derive(Debug, Clone, Deserialize)]
pub struct Section8283ARow {
    /// (a) Name and address of the donee organization.
    pub donee: String,
    /// (c) Description and condition of donated property.
    pub desc: String,
    /// (d) Date of the contribution (full date).
    pub date_contrib: String,
    /// (e) Date acquired by donor (mo., yr.).
    pub date_acq: String,
    /// (f) How acquired by donor.
    pub how: String,
    /// (g) Donor's cost or adjusted basis (money — a [`MoneyPair`] on the 2017 Rev. 12-2014 form).
    pub cost: MoneyCell,
    /// (h) Fair market value (money — a [`MoneyPair`] on the 2017 form).
    pub fmv: MoneyCell,
    /// (i) Method used to determine the FMV.
    pub method: String,
}

/// Form 8283 Section A (page 1, Line 1) — up to 4 rows A–D.
#[derive(Debug, Clone, Deserialize)]
pub struct Section8283A {
    /// The 4 rows A–D.
    pub rows: Vec<Section8283ARow>,
}

/// One Form 8283 **Section B Part I** row (Over $5,000): the filled columns.
#[derive(Debug, Clone, Deserialize)]
pub struct Section8283BRow {
    /// (a) Description of donated property.
    pub desc: String,
    /// (c) Appraised fair market value (money — a [`MoneyPair`] on the 2017 Rev. 12-2014 form).
    pub fmv: MoneyCell,
    /// (d) Date acquired by donor (mo., yr.).
    pub date_acq: String,
    /// (e) How acquired by donor.
    pub how: String,
    /// (f) Donor's cost or adjusted basis (money — a [`MoneyPair`] on the 2017 form).
    pub cost: MoneyCell,
    /// (i)/(h) Amount claimed as a deduction (carrier row only; money — a [`MoneyPair`] on 2017).
    pub deduction: MoneyCell,
}

/// Form 8283 Section B (page 1/2, over-$5,000 property + page 2 identity) — up to 3 rows (2024/2025)
/// or 4 rows (2017 Rev. 12-2014, `Line5A`–`Line5D`).
#[derive(Debug, Clone, Deserialize)]
pub struct Section8283B {
    /// The property-type checkbox MUST be checked for BTC: **"k Digital assets"** (on-state `/11`) on
    /// the Rev. 12-2023/2025 forms; the Rev. 12-2014 form has no digital-asset box, so 2017 uses
    /// **"j Other"** (on-state `/9`) plus [`Self::btc_property_note`].
    pub k_digital_assets: CheckChoice,
    /// 2017 only: since "j Other" gives no category, the digital-asset nature is identified by a
    /// printed note **prepended to the first row's (a) description** (e.g. "Other property: digital
    /// asset (virtual currency)"). `None` on 2024/2025 ("k Digital assets" is self-describing).
    #[serde(default)]
    pub btc_property_note: Option<String>,
    /// Part IV/III appraiser name (page 2). `None` when the revision has no printed-name field (the
    /// Rev. 12-2014 form: the appraiser identity is the handwritten signature, left blank).
    #[serde(default)]
    pub appraiser_name: Option<String>,
    /// Appraiser business address (page 2).
    pub appraiser_address: String,
    /// Appraiser identifying number (TIN/PTIN, page 2).
    pub appraiser_tin: String,
    /// Donee organization name (page 2).
    pub donee_name: String,
    /// Donee EIN (page 2).
    pub donee_ein: String,
    /// Donee address (page 2).
    pub donee_address: String,
    /// The rows (3 on 2024/2025, 4 on 2017) — the row count also sets the per-copy overflow cap.
    pub rows: Vec<Section8283BRow>,
}

/// The Form 8283 (Rev. 12-2025) field map for one tax year.
#[derive(Debug, Clone, Deserialize)]
pub struct Form8283Map {
    /// `"f8283"`.
    pub form: String,
    /// Tax year.
    pub year: i32,
    /// Section A (≤ $5,000).
    pub section_a: Section8283A,
    /// Section B (> $5,000).
    pub section_b: Section8283B,
}

impl Form8283Map {
    /// Parse the committed TOML.
    pub fn parse(toml_src: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(toml_src)
    }

    /// The TY2025 map.
    pub fn ty2025() -> Self {
        Self::parse(F8283_MAP_2025).expect("bundled f8283 2025 map parses")
    }

    /// The TY2024 map (Form 8283 Rev. 12-2023).
    pub fn ty2024() -> Self {
        Self::parse(F8283_MAP_2024).expect("bundled f8283 2024 map parses")
    }

    /// The TY2017 map (Form 8283 Rev. 12-2014 — "j Other", no DA box, 5/4 rows, ¢-pairs).
    pub fn ty2017() -> Self {
        Self::parse(F8283_MAP_2017).expect("bundled f8283 2017 map parses")
    }

    /// The map for a supported tax year.
    pub fn for_year(year: i32) -> Result<Self, FormsError> {
        match year {
            2017 => Ok(Self::ty2017()),
            2024 => Ok(Self::ty2024()),
            2025 => Ok(Self::ty2025()),
            _ => Err(FormsError::UnsupportedYear(year)),
        }
    }

    /// Every field name the map targets (for the `map_YYYY_matches_bundled_pdf_fieldset` guard).
    pub fn field_names(&self) -> Vec<&str> {
        let mut v = Vec::new();
        for r in &self.section_a.rows {
            v.extend([
                r.donee.as_str(),
                r.desc.as_str(),
                r.date_contrib.as_str(),
                r.date_acq.as_str(),
                r.how.as_str(),
            ]);
            v.extend(r.cost.fields());
            v.extend(r.fmv.fields());
            v.push(r.method.as_str());
        }
        let b = &self.section_b;
        v.push(b.k_digital_assets.field.as_str());
        if let Some(n) = &b.appraiser_name {
            v.push(n.as_str());
        }
        v.extend([
            b.appraiser_address.as_str(),
            b.appraiser_tin.as_str(),
            b.donee_name.as_str(),
            b.donee_ein.as_str(),
            b.donee_address.as_str(),
        ]);
        for r in &b.rows {
            v.extend([r.desc.as_str(), r.date_acq.as_str(), r.how.as_str()]);
            v.extend(r.fmv.fields());
            v.extend(r.cost.fields());
            v.extend(r.deduction.fields());
        }
        v
    }
}

/// The Schedule D field map for one tax year.
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduleDMap {
    /// `"schedule_d"`.
    pub form: String,
    /// Tax year.
    pub year: i32,
    /// Line 3 — Part I total from Form 8949 (Box C **or Box I**): columns d,e,g,h.
    pub line3: AmountCols,
    /// Line 7 — net short-term gain/loss (column h).
    pub line7_h: String,
    /// Line 10 — Part II total from Form 8949 (Box F **or Box L**): columns d,e,g,h.
    pub line10: AmountCols,
    /// Line 15 — net long-term gain/loss (column h).
    pub line15_h: String,
    /// Line 16 — total (line 7 + line 15), column h, page 2.
    pub line16_h: String,
    /// The Part I amount-column subform token used to re-derive the geometry bands — **per-year map
    /// config** (`Table_PartI` for 2024/2025, **`TablePartI`** (no underscore) for the 2017 form).
    #[serde(default = "default_sched_d_token")]
    pub table_token: String,
    /// QOF question "Yes" choice. `None` on years whose Schedule D has no QOF question (2017 —
    /// Qualified Opportunity Funds began in 2019).
    #[serde(default)]
    pub qof_yes: Option<CheckChoice>,
    /// QOF question "No" choice (answered No when present). `None` on 2017 (no QOF question).
    #[serde(default)]
    pub qof_no: Option<CheckChoice>,
}

/// The default Schedule D Part I grid token (2024/2025); the 2017 map overrides it to `TablePartI`.
fn default_sched_d_token() -> String {
    "Table_PartI".to_string()
}

impl ScheduleDMap {
    /// Parse the committed TOML.
    pub fn parse(toml_src: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(toml_src)
    }

    /// The TY2025 map.
    pub fn ty2025() -> Self {
        Self::parse(SCHEDULE_D_MAP_2025).expect("bundled schedule_d 2025 map parses")
    }

    /// The TY2024 map.
    pub fn ty2024() -> Self {
        Self::parse(SCHEDULE_D_MAP_2024).expect("bundled schedule_d 2024 map parses")
    }

    /// The TY2017 map (grid token `TablePartI`; NO QOF question).
    pub fn ty2017() -> Self {
        Self::parse(SCHEDULE_D_MAP_2017).expect("bundled schedule_d 2017 map parses")
    }

    /// The map for a supported tax year.
    pub fn for_year(year: i32) -> Result<Self, FormsError> {
        match year {
            2017 => Ok(Self::ty2017()),
            2024 => Ok(Self::ty2024()),
            2025 => Ok(Self::ty2025()),
            _ => Err(FormsError::UnsupportedYear(year)),
        }
    }
}

/// The Schedule SE (Form 1040) field map for one tax year — the filled §1401 line chain.
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduleSeMap {
    /// `"schedule_se"`.
    pub form: String,
    /// Tax year.
    pub year: i32,
    /// Line 2 — net profit (net_se), amount column.
    pub line2: MoneyCell,
    /// Line 3 — combine 1a/1b/2 (= line 2), amount column.
    pub line3: MoneyCell,
    /// Line 4a — net SE earnings (base = net_se × 92.35%), amount column.
    pub line4a: MoneyCell,
    /// Line 4c — combine 4a/4b (= line 4a), amount column. The $400 STOP threshold.
    pub line4c: MoneyCell,
    /// Line 6 — add 4c/5b (= line 4c), amount column.
    pub line6: MoneyCell,
    /// Line 8a — Form W-2 Social Security wages, **MID column**.
    pub line8a: MoneyCell,
    /// Line 8d — add 8a/8b/8c (= line 8a), amount column.
    pub line8d: MoneyCell,
    /// Line 9 — line 7 (`ss_wage_base` constant) − line 8d, amount column.
    pub line9: MoneyCell,
    /// Line 10 — Social Security portion (`ss`), amount column.
    pub line10: MoneyCell,
    /// Line 11 — regular Medicare portion (`medicare`), amount column.
    pub line11: MoneyCell,
    /// Line 12 — SE tax = line 10 + line 11 (**SS + regular Medicare ONLY**), amount column.
    pub line12: MoneyCell,
    /// Line 13 — one-half SE-tax deduction (= line 12 × 50% = `deductible_half`), **MID column**.
    pub line13: MoneyCell,
    /// Fields the BLANK form already carries a factory `/V` for (the 2017 §B long form pre-prints
    /// line 7 = `127,200`/`00` and line 14 = `5,200`/`00`) — excluded from the `no_unmapped_filled`
    /// guard so those constants don't read as stray writes. Empty on 2024/2025.
    #[serde(default)]
    pub prefilled_exempt: Vec<String>,
}

impl ScheduleSeMap {
    /// Parse the committed TOML.
    pub fn parse(toml_src: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(toml_src)
    }

    /// The TY2025 map.
    pub fn ty2025() -> Self {
        Self::parse(SCHEDULE_SE_MAP_2025).expect("bundled schedule_se 2025 map parses")
    }

    /// The TY2024 map (field-name-identical to 2025; only the wage base differs).
    pub fn ty2024() -> Self {
        Self::parse(SCHEDULE_SE_MAP_2024).expect("bundled schedule_se 2024 map parses")
    }

    /// The TY2017 map (OLD §B long form: dollars+cents pairs; pre-filled line 7/14 exempt).
    pub fn ty2017() -> Self {
        Self::parse(SCHEDULE_SE_MAP_2017).expect("bundled schedule_se 2017 map parses")
    }

    /// The map for a supported tax year.
    pub fn for_year(year: i32) -> Result<Self, FormsError> {
        match year {
            2017 => Ok(Self::ty2017()),
            2024 => Ok(Self::ty2024()),
            2025 => Ok(Self::ty2025()),
            _ => Err(FormsError::UnsupportedYear(year)),
        }
    }

    /// The 12 filled line cells, in chain order.
    pub fn lines(&self) -> [&MoneyCell; 12] {
        [
            &self.line2,
            &self.line3,
            &self.line4a,
            &self.line4c,
            &self.line6,
            &self.line8a,
            &self.line8d,
            &self.line9,
            &self.line10,
            &self.line11,
            &self.line12,
            &self.line13,
        ]
    }

    /// Every field name the map targets (for the `map_YYYY_matches_bundled_pdf_fieldset` guard) —
    /// both members of each dollars+cents pair on the 2017 form.
    pub fn field_names(&self) -> Vec<&str> {
        self.lines().iter().flat_map(|c| c.fields()).collect()
    }
}