diff --git a/forms/f1040/2025/pdf/forms/deductions/additional/schedule1a/schedule1a.ts b/forms/f1040/2025/pdf/forms/deductions/additional/schedule1a/schedule1a.ts index e4ba8b6f1..124dde0eb 100644 --- a/forms/f1040/2025/pdf/forms/deductions/additional/schedule1a/schedule1a.ts +++ b/forms/f1040/2025/pdf/forms/deductions/additional/schedule1a/schedule1a.ts @@ -236,14 +236,15 @@ const fields: ReadonlyArray = [ }, ]; -export const schedule1aPdf: PdfFormDescriptor = { +export function schedule1aPdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "schedule1a", pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040s1a--2025.pdf", projectFields(raw, allPending) { if (Object.keys(raw).length === 0) return raw; const input = inputSchema.parse(raw); // Keep the PDF authorization identical to native XML authorization. - if (!schedule1a.build(input, { pending: allPending })) return {}; + if (!schedule1a.build(input, { pending: allPending, taxYear })) return {}; const projected: Record = { line1_agi: input.magi, line2b_form2555_exclusion: input.form2555_line45_exclusion, @@ -260,7 +261,7 @@ export const schedule1aPdf: PdfFormDescriptor = { (input.qualified_trade_business_tips?.length ?? 0) > 0 ) { const lines = calculateQualifiedTipsSchedule1A( - { taxYear: 2025, formType: "f1040" }, + { taxYear, formType: "f1040" }, input, ); Object.assign(projected, { @@ -281,7 +282,7 @@ export const schedule1aPdf: PdfFormDescriptor = { } if ((input.qualified_w2_overtime?.length ?? 0) > 0) { const lines = calculateW2OvertimeSchedule1A( - { taxYear: 2025, formType: "f1040" }, + { taxYear, formType: "f1040" }, input, ); Object.assign(projected, { @@ -300,7 +301,7 @@ export const schedule1aPdf: PdfFormDescriptor = { } if ((input.vehicle_loans?.length ?? 0) > 0) { const lines = calculateVehicleInterestSchedule1A( - { taxYear: 2025, formType: "f1040" }, + { taxYear, formType: "f1040" }, input, ); const [first, second] = lines.line22_vehicles; @@ -331,9 +332,9 @@ export const schedule1aPdf: PdfFormDescriptor = { }); total += lines.line30_vehicle_interest; } - if (seniorDeduction({ taxYear: 2025, formType: "f1040" }, input) > 0) { + if (seniorDeduction({ taxYear, formType: "f1040" }, input) > 0) { const lines = calculateSeniorOnlySchedule1A( - { taxYear: 2025, formType: "f1040" }, + { taxYear, formType: "f1040" }, input, ); Object.assign(projected, { @@ -362,4 +363,7 @@ export const schedule1aPdf: PdfFormDescriptor = { pdfField: `${page1}.f1_02[0]`, }, ], -}; + }; +} + +export const schedule1aPdf: PdfFormDescriptor = schedule1aPdfFor(2025); diff --git a/forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts b/forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts index 08fbcbc27..44e1eccbf 100644 --- a/forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts +++ b/forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts @@ -1,4 +1,3 @@ -import { mefTaxYear } from "../../../../mef/support/year-config.ts"; import { assertRetirementStateLocalTaxSource } from "../../../../domains/income/retirement/retirement_state_local_tax_source.ts"; import type { PdfFieldEntry, @@ -222,7 +221,8 @@ function line16Description( : undefined; } -export const scheduleAPdf: PdfFormDescriptor = { +export function scheduleAPdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "schedule_a", pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040sa--2025.pdf", filerFields: [ @@ -322,7 +322,7 @@ export const scheduleAPdf: PdfFormDescriptor = { all.f1098, recipients, Number(input.line_8c_points_no_1098 ?? 0), - mefTaxYear(), + taxYear, ); } const hasPriorCarryover = [ @@ -348,14 +348,18 @@ export const scheduleAPdf: PdfFormDescriptor = { assertRetirementStateLocalTaxSource(all, input); const parsed = scheduleAInputSchema.parse(source); const recomputed = scheduleA.compute( - { taxYear: 2025, formType: "f1040" }, + { taxYear, formType: "f1040" }, parsed, ); const standard = recomputed.outputs.find((output) => output.nodeType === "standard_deduction" )?.fields; + // TY2026 Form 1040 line 12e is after the IRC §68 reduction. + const overallLimit = Number( + all?.standard_deduction?.overall_itemized_limit_reduction ?? 0, + ); if ( - !standard || standard.itemized_deductions !== + !standard || Number(standard.itemized_deductions) - overallLimit !== all?.f1040?.line12e_itemized_deductions ) { throw new Error( @@ -405,7 +409,7 @@ export const scheduleAPdf: PdfFormDescriptor = { const line16Rows = scheduleALine16AllRows( all, input.line_16_other_deductions, - { taxYear: mefTaxYear(), formType: "f1040" }, + { taxYear, formType: "f1040" }, ); const line16 = input.line_16_other_deductions === undefined && input.net_qualified_disaster_loss === undefined @@ -509,4 +513,7 @@ export const scheduleAPdf: PdfFormDescriptor = { } return true; }, -}; + }; +} + +export const scheduleAPdf: PdfFormDescriptor = scheduleAPdfFor(2025); diff --git a/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e.ts b/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e.ts index 16a7c5d51..1948c127d 100644 --- a/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e.ts +++ b/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e.ts @@ -1,4 +1,3 @@ -import { mefTaxYear } from "../../../../mef/support/year-config.ts"; import { StandardFonts } from "pdf-lib"; import { currentPropertyPassiveAmounts } from "../../../../../nodes/inputs/income/rental-passthrough/schedule_e/current-property-source.ts"; import type { @@ -236,7 +235,8 @@ function retainedScheduleEPages(projected: Record): number[] { return partI && partII ? [0, 1] : partII ? [1] : [0]; } -export const scheduleEPdf: PdfFormDescriptor = { +export function scheduleEPdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "schedule_e", pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040se--2025.pdf", pageIndices: retainedScheduleEPages, @@ -261,7 +261,7 @@ export const scheduleEPdf: PdfFormDescriptor = { ], projectFields(raw, allPending) { const k1Rows = scheduleEK1Part2Rows(allPending, { - taxYear: mefTaxYear(), + taxYear, formType: "f1040", }); if (Object.keys(raw).length === 0 && k1Rows.length === 0) return {}; @@ -297,7 +297,7 @@ export const scheduleEPdf: PdfFormDescriptor = { input.rental_income !== undefined || input.royalty_income !== undefined || trustRows.length > 2 || - !scheduleE.build(input, { pending: allPending }) + !scheduleE.build(input, { pending: allPending, taxYear }) ) { throw new Error( "Schedule E PDF Part II needs up to four sourced K-1 rows on a supported page 2", @@ -363,7 +363,7 @@ export const scheduleEPdf: PdfFormDescriptor = { input.rental_income !== undefined || input.royalty_income !== undefined || trustRows.length > 2 || - !scheduleE.build(input, { pending: allPending }) + !scheduleE.build(input, { pending: allPending, taxYear }) ) { throw new Error( "Schedule E PDF trust rows need a sourced Part III return", @@ -395,7 +395,7 @@ export const scheduleEPdf: PdfFormDescriptor = { if (input.schedule_es.length === 0) return partIIFields!; } if (input.schedule_es.length === 0 && input.farm_rental_net !== undefined) { - if (!scheduleE.build(input, { pending: allPending })) { + if (!scheduleE.build(input, { pending: allPending, taxYear })) { throw new Error( "Schedule E PDF farm rental needs a native Form 4835 join", ); @@ -449,6 +449,7 @@ export const scheduleEPdf: PdfFormDescriptor = { ).some(Boolean); const allowedByActivity = validatePassiveActivityLink(items, { pending: allPending, + taxYear, }); // Native Schedule E checks every source row, limitation, and finalized total. if (items[0].f1099m_royalty_source) { @@ -458,7 +459,7 @@ export const scheduleEPdf: PdfFormDescriptor = { undefined, allPending, ); - } else if (!scheduleE.build(input, { pending: allPending })) { + } else if (!scheduleE.build(input, { pending: allPending, taxYear })) { throw new Error("Schedule E PDF needs native Part I properties"); } const rows = items.map((item, index) => { @@ -666,7 +667,7 @@ export const scheduleEPdf: PdfFormDescriptor = { if (input?.schedule_es[0]?.f1099m_royalty_source) { if ( !filer || !allPending || - !scheduleE.build(input, { pending: allPending, filer }) + !scheduleE.build(input, { pending: allPending, filer, taxYear }) ) { throw new Error( "Schedule E PDF linked 1099-MISC royalty needs its primary filer and native property", @@ -692,4 +693,7 @@ export const scheduleEPdf: PdfFormDescriptor = { filer, ); }, -}; + }; +} + +export const scheduleEPdf: PdfFormDescriptor = scheduleEPdfFor(2025); diff --git a/forms/f1040/2026/README.md b/forms/f1040/2026/README.md index ed71626ba..e728cc106 100644 --- a/forms/f1040/2026/README.md +++ b/forms/f1040/2026/README.md @@ -6,13 +6,14 @@ normalization with the 2026v1.0 schema. It accepts every input TY2025 accepts; there is no allowlist. -PDF output (`pdf/builder.ts`) prints Form 1040, Schedules 1, 2, 3, B, C, D and -SE and Forms 2441, 8863, 8889 and 8995 on the IRS 2026 early-release drafts, fetched from `irs.gov/pub/irs-dft/` and pinned by +PDF output (`pdf/builder.ts`) prints Form 1040, Schedules 1, 1-A, 2, 3, A, B, C, D, +E and SE and Forms 2441, 8863, 8889 and 8995 on the IRS 2026 early-release drafts, fetched from `irs.gov/pub/irs-dft/` and pinned by SHA-256 like the 2025 templates; the IRS cover sheet is dropped and the packet's PDF Subject and the CLI say it uses draft forms. Each overlay in `pdf/forms/` reuses the 2025 projection, built for the 2026 tax year, and renames only the fields the 2026 form moved; new 2026 lines the engine does not model (Form 8995 -line 16 minimum deduction, 2025-titled continuation statements) refuse by name. +line 16 minimum deduction, Schedule A lines 17a-17k, Schedule 1-A tip and +overtime rows, 2025-titled continuation statements) refuse by name. Every other form refuses by name when the return needs it, as do refundable credits that need Schedule 3-A (Form 1040 lines 27a-30). A redrafted IRS PDF fails its digest until the mapping is reviewed. diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index e4d6074d1..97ad52cc5 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -10,6 +10,7 @@ import { Box12Code } from "../../nodes/inputs/income/wages/w2/index.ts"; import { f1040_2026 } from "../index.ts"; import { TY2026_DRAFT_NOTICE } from "./draft-template.ts"; import { TY2026_PDF_MAPPED_FORMS, ty2026PdfForms } from "./builder.ts"; +import { scheduleEPdf2026 } from "./forms/income/rental-passthrough/schedule_e.ts"; import { loadPdfTemplate } from "../../2025/pdf/support/template-cache.ts"; const fixture = (id: string) => { @@ -221,6 +222,112 @@ test("TY2026 PDF prints Form 2441 Part I providers as columns", async () => { assertMatch(printed, /on Schedule 3 \(Form 1040\), line 2[^\n]* 11 500/); }); +test("TY2026 PDF prints Schedule E on the 2026 draft pages", async () => { + const rental = fixture("single-nonparticipating-rental-loss"); + const [property] = rental.schedule_e as Array>; + const trust = fixture("single-trust-k1-box5-schedule-e"); + const doc = await render({ + ...rental, + k1_trust: trust.k1_trust, + schedule_e: [{ + ...property, + activity_type: "A", + rent_income: 12000, + expense_repairs: 1000, + expense_mortgage_interest: 2000, + expense_other_interest: 300, + }], + }); + const printed = await text(doc); + assertMatch(printed, /SCHEDULE E[\s\S]*2026/); + assertMatch(printed, /A 12 Example Street Austin TX 78701/); + assertMatch(printed, /3 Rents received[^\n]* 3 12000/); + assertMatch(printed, /12 Mortgage interest[^\n]* 12 2000/); + assertMatch(printed, /13 Interest \(see instructions\)[^\n]* 13\n/); + assertMatch(printed, /13b Other[^\n]* 13b 300/); + assertMatch(printed, /14 Repairs[^\n]* 14 1000/); + assertMatch(printed, /20 Total expenses[^\n]* 20 3300/); + assertMatch(printed, /23a Total of all amounts reported on line 3[^\n]* 23a 12000/); + assertMatch(printed, /23e Total of all amounts reported on line 20[^\n]* 23e 3300/); + assertMatch(printed, /24 Income\. Add positive amounts[^\n]* 24 8700/); + assertMatch(printed, /26 Total rental real estate[\s\S]*? 26 8700/); + assertMatch(printed, /A Family Trust[^\n]* 123456789/); + assertMatch(printed, /37 Total estate and trust income[^\n]* 37 750/); + assertMatch(printed, /41 Total income or \(loss\)\. Combine[\s\S]*? 41 9450/); + const k1Columns = scheduleEPdf2026.fields + .filter((entry) => entry.domainKey.startsWith("k1_0_")) + .map((entry) => [entry.domainKey, entry.pdfField.split(".").pop()]); + assertEquals(Object.fromEntries(k1Columns), { + k1_0_name: "f2_6[0]", + k1_0_code: "f2_7[0]", + k1_0_ein: "f2_8[0]", + k1_0_passive_loss: "f2_18[0]", + k1_0_passive_income: "f2_19[0]", + k1_0_basis_required: "c2_3[0]", + k1_0_nonpassive_income: "f2_22[0]", + }); +}); + +test("TY2026 PDF prints Schedule A with the 0.5% gift floor and line 18", async () => { + const itemizer = fixture("single-1098-purchase-points"); + const schedule_a = { + line_5b_real_estate_tax: 4000, + cash_contributions_to_50_percent_organizations: 10000, + }; + const printed = await text(await render({ ...itemizer, schedule_a })); + assertMatch(printed, /SCHEDULE A[\s\S]*2026/); + assertMatch(printed, /line 11b[^\n]* 2 80000/); + assertMatch(printed, /7 Add lines 5e and 6[^\n]* 7 4000/); + assertMatch(printed, /8e Add lines 8a through 8d[^\n]* 8e 20400/); + assertMatch(printed, /10 Add lines 8e and 9[^\n]* 10 20400/); + assertMatch(printed, /see instructions[^\n]* 11 10000/); + assertMatch(printed, /Limitation Worksheet[^\n]* 13 9600/); + assertMatch(printed, /15 Add lines 13 and 14[^\n]* 15 9600/); + assertMatch(printed, /✔ No\. Your deductions are not limited/); + assertMatch(printed, /the amount to enter[^\n]* 18 34000/); + assertMatch(printed, /12e Standard deduction or itemized[^\n]* 12e 34000/); +}); + +test("TY2026 PDF prints Schedule 1-A car loan and senior parts", async () => { + const car = await text( + await render(fixture("single-reviewed-car-loan-schedule1a")), + ); + assertMatch(car, /SCHEDULE 1-A[\s\S]*2026/); + assertMatch(car, /line 11b[^\n]* 1 80000/); + assertMatch(car, /28a 1 H G C M 8 2 6 3 3 A 0 0 4 3 5 2 0 4000/); + assertMatch(car, /does not qualify\)\?[^\n]* ✔ Yes No/); + assertMatch(car, /final assembly\)\?[^\n]* ✔ Yes No/); + assertMatch(car, /29 Add lines 28a and 28b[^\n]* 29 4000/); + assertMatch(car, /32 Enter \$100,000[^\n]* 32 100000/); + assertMatch(car, /enter -0- [. ]* 36 4000/); + assertMatch(car, /44 Add lines 15, 27, 36, and 43[^\n]* 44 4000/); + const senior = await text(await render(fixture("joint-senior-schedule1a"))); + assertMatch(senior, /37 Enter the amount from line 3[^\n]* 37 160000/); + assertMatch(senior, /40 Multiply line 39 by 6%[^\n]* 40 600/); + assertMatch(senior, /41 Subtract line 40 from \$6,000[^\n]* 41 5400/); + assertMatch(senior, /43 Enhanced deduction for seniors[^\n]* 43 10800/); + assertMatch(senior, /44 Add lines 15, 27, 36, and 43[^\n]* 44 10800/); + await assertRejects( + () => render(fixture("single-w2-qualified-tips-schedule1a")), + Error, + "Schedule 1-A tips, overtime", + ); +}); + +test("TY2026 PDF prints Form 8863 on the 2026 draft", async () => { + // The fixture's worksheet holds 2025 tax; the 2026 return supplies its own. + const { f8863_credit_limit_worksheet: _, ...inputs } = fixture( + "single-form8863-lifetime-learning-scholarship", + ); + const printed = await text(await render(inputs)); + assertMatch(printed, /Education Credits[\s\S]*2026/); + assertMatch(printed, /11 Enter the smaller of line 10 or \$10,000[^\n]* 11 7500/); + assertMatch(printed, /12 Multiply line 11 by 20%[^\n]* 12 1500/); + assertMatch(printed, /Schedule 3 \(Form 1040\), line 3[^\n]* 19 1500/); + assertMatch(printed, /\n Test University\n/); + assertMatch(printed, /Part II, line 10[^\n]* 31 7500/); +}); + test("TY2026 PDF refuses, by name, a form with no TY2026 mapping", async () => { await assertRejects( () => render(fixture("single-child-unearned-income")), diff --git a/forms/f1040/2026/pdf/builder.ts b/forms/f1040/2026/pdf/builder.ts index 4c3ee151c..8c95d33cb 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -14,17 +14,23 @@ import { form2441Pdf2026 } from "./forms/credits/individual/f2441.ts"; import { scheduleBPdf2026 } from "./forms/income/investments/schedule_b.ts"; import { scheduleDPdf2026 } from "./forms/income/investments/schedule_d.ts"; import { form8889Pdf2026 } from "./forms/adjustments/health/f8889.ts"; +import { scheduleEPdf2026 } from "./forms/income/rental-passthrough/schedule_e.ts"; +import { scheduleAPdf2026 } from "./forms/deductions/itemized/schedule_a.ts"; +import { schedule1aPdf2026 } from "./forms/deductions/additional/schedule1a.ts"; import { form8863Pdf2026 } from "./forms/credits/individual/f8863.ts"; const MAPPED: ReadonlyMap = new Map( [ irs1040Pdf2026, schedule1Pdf2026, + schedule1aPdf2026, schedule2Pdf2026, schedule3Pdf2026, + scheduleAPdf2026, scheduleBPdf2026, scheduleCPdf2026, scheduleDPdf2026, + scheduleEPdf2026, scheduleSePdf2026, form2441Pdf2026, form8863Pdf2026, diff --git a/forms/f1040/2026/pdf/forms/deductions/additional/schedule1a.ts b/forms/f1040/2026/pdf/forms/deductions/additional/schedule1a.ts new file mode 100644 index 000000000..293007fbc --- /dev/null +++ b/forms/f1040/2026/pdf/forms/deductions/additional/schedule1a.ts @@ -0,0 +1,114 @@ +import { schedule1aPdfFor } from "../../../../../2025/pdf/forms/deductions/additional/schedule1a/schedule1a.ts"; +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template, withoutStatements } from "../../../draft-template.ts"; + +// IRS 2026 Schedule 1-A draft (page 1 is the IRS cover sheet). Part I keeps +// its fields. Tips (Part II) and overtime (Part III) now list each employer +// and business by row; those rows are not mapped, so a return with either +// refuses. Car loan interest moves to page 3 as lines 28-36 with original +// use and U.S. assembly questions, seniors become lines 37-43 and the total +// line 44. +const P2 = "form1[0].Page2[0]"; +const P3 = "form1[0].Page3[0]"; + +const UNMAPPED_PARTS = [ + "line4a_w2_tips", + "line4b_form4137_tips", + "line4c_employee_tips", + "line5_trade_business_tips", + "line6_total_tips", + "line7_capped_tips", + "line8_magi", + "line9_threshold", + "line10_excess_magi", + "line11_thousands", + "line12_reduction", + "line13_tips", + "line14a_w2_overtime", + "line14b_zero_1099", + "line14c_total_overtime", + "line15_capped_overtime", + "line16_magi", + "line17_threshold", + "line18_excess_magi", + "line19_thousands", + "line20_reduction", + "line21_overtime", +]; + +const vehicleRow = (row: "a" | "b", n: number) => ({ + [`${P2}.Table_Line22[0].Line22${row}[0].VIN-${n}_Comb[0].f2_0${ + n * 3 - 2 + }[0]`]: `${P3}.Table_Line28${row}[0].Line28${row}[0].VIN-${n}_Comb[0].f3_0${ + n * 3 - 2 + }[0]`, + [`${P2}.Table_Line22[0].Line22${row}[0].f2_0${n * 3 - 1}[0]`]: + `${P3}.Table_Line28${row}[0].Line28${row}[0].f3_0${n * 3 - 1}[0]`, + [`${P2}.Table_Line22[0].Line22${row}[0].f2_0${n * 3}[0]`]: + `${P3}.Table_Line28${row}[0].Line28${row}[0].f3_0${n * 3}[0]`, +}); + +const rename: Readonly> = { + ...vehicleRow("a", 1), + ...vehicleRow("b", 2), + ...Object.fromEntries( + Array.from({ length: 17 }, (_, index) => [ + `${P2}.f2_${String(7 + index).padStart(2, "0")}[0]`, + `${P3}.f3_${String(7 + index).padStart(2, "0")}[0]`, + ]), + ), +}; + +// Yes answers come from the loan's required source facts (original use and +// U.S. final assembly are literal `true` in the Schedule 1-A input). +const vehicleAnswers: readonly PdfFieldEntry[] = [ + ["line22a_vin", 1], + ["line22a_vin", 2], + ["line22b_vin", 3], + ["line22b_vin", 4], +].map(([domainKey, box]): PdfFieldEntry => ({ + kind: "checkbox", + domainKey: `ty2026_${domainKey}_qualifies`, + pdfField: `${P3}.c3_${box}[0]`, +})); + +const base = schedule1aPdfFor(2026); +const draft = onTy2026Template( + { ...base, pageIndices: () => [0, 2] }, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040s1a--dft.pdf", + pdfSha256: + "e22aec7feb2f5b734e16ee74d70acfdfdc0d66a5292f69cf0ced3b87543d95ac", + formPages: [1, 2, 3], + }, + rename, + UNMAPPED_PARTS, + vehicleAnswers, +); + +export const schedule1aPdf2026: PdfFormDescriptor = withoutStatements( + { + ...draft, + projectFields(raw, allPending) { + const projected = base.projectFields?.(raw, allPending) ?? raw; + if ( + UNMAPPED_PARTS.some((key) => + projected[key] !== undefined && projected[key] !== 0 + ) || projected.line22_overflow_vehicles !== undefined + ) { + throw new Error( + "TY2026 PDF Schedule 1-A tips, overtime and more than two vehicles are not mapped; export MeF XML", + ); + } + return { + ...projected, + ty2026_line22a_vin_qualifies: projected.line22a_vin !== undefined, + ty2026_line22b_vin_qualifies: projected.line22b_vin !== undefined, + }; + }, + }, + "Schedule 1-A", +); diff --git a/forms/f1040/2026/pdf/forms/deductions/itemized/schedule_a.ts b/forms/f1040/2026/pdf/forms/deductions/itemized/schedule_a.ts new file mode 100644 index 000000000..6e0ecef2f --- /dev/null +++ b/forms/f1040/2026/pdf/forms/deductions/itemized/schedule_a.ts @@ -0,0 +1,139 @@ +import { scheduleAPdfFor } from "../../../../../2025/pdf/forms/deductions/itemized/schedule_a.ts"; +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { CONFIG_BY_YEAR } from "../../../../../nodes/config/index.ts"; +import { topBracketThreshold } from "../../../../../nodes/intermediate/worksheets/deductions/standard/standard_deduction/deduction-limits-2026.ts"; +import { inputSchema as standardDeductionInputSchema } from "../../../../../nodes/intermediate/worksheets/deductions/standard/standard_deduction/index.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Schedule A draft (page 1 is the IRS cover sheet). Gifts to charity +// move to page 2 and gain line 13, the gifts above the IRC §170(b)(1)(I) 0.5% +// AGI floor; the carryover becomes line 14 and the total line 15. Line 16 is +// casualty loss, line 17 splits other deductions into 17a-17k, and line 18 +// asks whether the IRC §68 limit applies before the filed total. +const P1 = "form1[0].Page1[0]"; +const P2 = "form1[0].Page2[0]"; + +const rename: Readonly> = { + [`${P1}.f1_1[0]`]: `${P1}.f1_01[0]`, + [`${P1}.f1_2[0]`]: `${P1}.f1_02[0]`, + [`${P1}.f1_3[0]`]: `${P1}.f1_03[0]`, + [`${P1}.Line2_ReadOrder[0].f1_4[0]`]: `${P1}.f1_04[0]`, + [`${P1}.f1_5[0]`]: `${P1}.f1_05[0]`, + [`${P1}.f1_6[0]`]: `${P1}.f1_06[0]`, + [`${P1}.f1_7[0]`]: `${P1}.f1_07[0]`, + [`${P1}.f1_8[0]`]: `${P1}.f1_08[0]`, + [`${P1}.f1_9[0]`]: `${P1}.f1_09[0]`, + [`${P1}.Line8b_ReadOrder[0].f1_16[0]`]: `${P1}.f1_17[0]`, + [`${P1}.f1_17[0]`]: `${P1}.f1_16[0]`, + [`${P1}.f1_23[0]`]: `${P2}.Line11_ReadOrder[0].f2_01[0]`, + [`${P1}.f1_24[0]`]: `${P2}.f2_02[0]`, + [`${P1}.f1_25[0]`]: `${P2}.f2_04[0]`, + [`${P1}.f1_26[0]`]: `${P2}.f2_05[0]`, + [`${P1}.f1_27[0]`]: `${P2}.f2_06[0]`, + [`${P1}.f1_30[0]`]: `${P2}.f2_19[0]`, + [`${P1}.Line18_ReadOrder[0].c1_3[0]`]: `${P2}.Line19_ReadOrder[0].c2_2[0]`, +}; + +const added: readonly PdfFieldEntry[] = [ + { + kind: "text", + domainKey: "line_8d_mortgage_insurance_allowed", + pdfField: `${P1}.f1_19[0]`, + }, + { + kind: "text", + domainKey: "ty2026_line13_charity_after_floor", + pdfField: `${P2}.f2_03[0]`, + }, + { + kind: "checkboxWhen", + domainKey: "ty2026_line18_limited", + pdfField: `${P2}.c2_1[0]`, + whenValue: "false", + }, + { + kind: "checkboxWhen", + domainKey: "ty2026_line18_limited", + pdfField: `${P2}.c2_1[1]`, + whenValue: "true", + }, +]; + +const base = scheduleAPdfFor(2026); +const draft = onTy2026Template( + base, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040sa--dft.pdf", + pdfSha256: + "d49acff9afc2faa178b7bf1f70d826d03c2c6def43700880f8ae5bb8bb5a0ffa", + formPages: [1, 2], + }, + rename, + ["print_line_16_description", "line_16_other_deductions"], + added, +); + +const amount = (fields: Record, key: string) => + Number(fields[key] ?? 0); + +/** The 2025 copy moved onto the 2026 lines, or a named refusal. */ +function on2026Lines( + copy: Record, + all: Record>, +): Record { + if ( + copy.print_increased_standard === true || + copy.line_16_other_deductions !== undefined || + copy.net_qualified_disaster_loss !== undefined + ) { + throw new Error( + "TY2026 PDF Schedule A lines 17a-17k are not mapped; export MeF XML", + ); + } + const standard = standardDeductionInputSchema.parse(all.standard_deduction); + const insurance = amount(copy, "line_8d_mortgage_insurance_allowed"); + const line10 = amount(copy, "line_10_interest") + insurance; + const gifts = [ + "line_11_cash_contributions", + "line_12_noncash_contributions", + ].some((key) => copy[key] !== undefined); + const line13 = amount(copy, "line_11_cash_contributions") + + amount(copy, "line_12_noncash_contributions") - + amount(copy, "charitable_agi_floor"); + const line15 = line13 + amount(copy, "line_13_contribution_carryover"); + const filed = all.f1040?.line12e_itemized_deductions; + const reduction = standard.overall_itemized_limit_reduction ?? 0; + const total = amount(copy, "line_4_medical_deduction") + + amount(copy, "line_7_taxes") + line10 + line15 + + amount(copy, "line_15_casualty_theft_loss"); + if (typeof filed !== "number" || total - reduction !== filed) { + throw new Error( + "TY2026 PDF Schedule A lines 4 through 16 differ from Form 1040 line 12e", + ); + } + const income = Math.round(standard.agi) - + Math.round(standard.qbi_deduction ?? 0) - + Math.round(standard.additional_deductions ?? 0); + return { + ...copy, + line_8e_mortgage_interest: amount(copy, "line_8e_mortgage_interest") + + insurance, + line_10_interest: line10, + ty2026_line13_charity_after_floor: gifts ? line13 : undefined, + line_14_charity: line15, + line_17_itemized: filed, + ty2026_line18_limited: income > + topBracketThreshold(CONFIG_BY_YEAR[2026], standard.filing_status), + }; +} + +export const scheduleAPdf2026: PdfFormDescriptor = { + ...draft, + instances(fields, filer, all, ...rest) { + const copies = base.instances?.(fields, filer, all, ...rest) ?? [fields]; + return copies.map((copy) => on2026Lines(copy, all ?? {})); + }, +}; diff --git a/forms/f1040/2026/pdf/forms/income/rental-passthrough/schedule_e.ts b/forms/f1040/2026/pdf/forms/income/rental-passthrough/schedule_e.ts new file mode 100644 index 000000000..44178a5d0 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/income/rental-passthrough/schedule_e.ts @@ -0,0 +1,225 @@ +import { scheduleEPdfFor } from "../../../../../2025/pdf/forms/income/rental-passthrough/schedule_e.ts"; +import type { z } from "zod"; +import { inputSchema } from "../../../../../nodes/inputs/income/rental-passthrough/schedule_e/index.ts"; +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template, withoutStatements } from "../../../draft-template.ts"; + +// IRS 2026 Schedule E draft (page 1 is the IRS cover sheet). Line 1a splits +// each address into street, apt., city, state and ZIP; line 13 becomes 13a +// vehicle loan and 13b other, so the engine's one "other interest" amount +// prints on 13b. Lines 24-26 move to page 2 and Part V to a new page 3, so +// every later field renumbers. +const P1 = "topmostSubform[0].Page1[0]"; +const P2 = "topmostSubform[0].Page2[0]"; +const P3 = "topmostSubform[0].Page3[0]"; +const ROWS = ["A", "B", "C"] as const; + +const text = (domainKey: string, pdfField: string): PdfFieldEntry => ({ + kind: "text", + domainKey, + pdfField, +}); + +const range = (from: number, count: number) => + Array.from({ length: count }, (_, index) => from + index); + +/** Page 1 fields keep their container and move by `by`. */ +const shift = ( + container: string, + from: number, + count: number, + by: number, + target = container, +): Array<[string, string]> => + range(from, count).map((n) => [ + `${container}.f1_${n}[0]`, + `${target}.f1_${n + by}[0]`, + ]); + +/** Page 2 fields after line 26 move by the three line 24-26 fields. */ +const shift2 = ( + container: string, + from: number, + count: number, +): Array<[string, string]> => + range(from, count).map((n) => [ + `${container}.f2_${n}[0]`, + `${container}.f2_${n + 3}[0]`, + ]); + +const expenses = `${P1}.Table_Expenses[0]`; +const expenseRename = (line: string, from: number, by: number, to = line) => + shift( + `${expenses}.Line${line}[0]`, + from, + 3, + by, + `${expenses}.Line${to}[0]`, + ); + +const k1Rows = ["A", "B", "C", "D"]; +const trustRows = ["A", "B"]; + +const rename: Readonly> = Object.fromEntries([ + ...ROWS.map((row, index): [string, string] => [ + `${P1}.Table_Line1b[0].Row${row}[0].f1_${6 + index}[0]`, + `${P1}.Table_Line1b[0].Row${row}[0].f1_${18 + index}[0]`, + ]), + ...ROWS.flatMap((row, index) => + shift(`${P1}.Table_Line2[0].Row${row}[0]`, 9 + index * 2, 2, 12) + ), + [`${P1}.f1_15[0]`, `${P1}.f1_27[0]`], + ...shift(`${P1}.Table_Income[0].Line3[0]`, 16, 3, 12), + ...shift(`${P1}.Table_Income[0].Line4[0]`, 19, 3, 12), + ...range(5, 14).flatMap((line) => + expenseRename( + String(line), + 22 + (line - 5) * 3, + line < 13 ? 12 : 18, + line === 13 ? "13b" : String(line), + ) + ), + [`${expenses}.Line19[0].f1_64[0]`, `${expenses}.Line19[0].f1_82[0]`], + ...expenseRename("19", 65, 18), + ...expenseRename("20", 68, 18), + ...expenseRename("21", 71, 18), + ...expenseRename("22", 74, 18), + ...shift(P1, 77, 5, 18), + ...range(82, 3).map((n, index): [string, string] => [ + `${P1}.f1_${n}[0]`, + `${P2}.f2_${3 + index}[0]`, + ]), + ...k1Rows.flatMap((row, index) => [ + ...shift2(`${P2}.Table_Line28a-f[0].Row${row}[0]`, 3 + index * 3, 3), + ...shift2(`${P2}.Table_Line28g-k[0].Row${row}[0]`, 15 + index * 5, 5), + ]), + ...shift2(P2, 35, 13), + ...trustRows.flatMap((row, index) => [ + ...shift2(`${P2}.Table_Line33a-b[0].Row${row}[0]`, 48 + index * 2, 2), + ...shift2(`${P2}.Table_Line33c-f[0].Row${row}[0]`, 52 + index * 4, 4), + ]), + ...shift2(P2, 60, 11), + [`${P2}.f2_77[0]`, `${P3}.f3_3[0]`], + [`${P2}.f2_78[0]`, `${P3}.f3_4[0]`], + [`${P2}.Line42_ReadOrder[0].f2_79[0]`, `${P3}.Line42_ReadOrder[0].f3_5[0]`], +]); + +const addressFields: readonly PdfFieldEntry[] = ROWS.flatMap((row, index) => + ["street", "apt", "city", "state", "zip"].map((part, column) => + text( + `ty2026_property_${index}_${part}`, + `${P1}.Line1aTable[0].Row${row}[0].f1_${3 + index * 5 + column}[0]`, + ) + ) +); + +/** Part I needs page 2 for lines 24-26; Part V is on page 3. */ +function scheduleEPages(fields: Record): number[] { + const partI = fields.line26 !== undefined; + const partIIOrIII = fields.trust_line37 !== undefined || + fields.k1_line32 !== undefined; + const partV = fields.farm_line40 !== undefined || + fields.trust_line41 !== undefined; + const pages = [ + ...(partI || !(partIIOrIII || partV) ? [0] : []), + ...(partI || partIIOrIII ? [1] : []), + ...(partV ? [2] : []), + ]; + return pages; +} + +const onPage = (page: number) => (fields: Record) => + scheduleEPages(fields).includes(page); + +const filerFields: PdfFormDescriptor["filerFields"] = [ + [`${P1}.f1_1[0]`, `${P1}.f1_2[0]`, 0], + [`${P2}.f2_1[0]`, `${P2}.f2_2[0]`, 1], + [`${P3}.f3_1[0]`, `${P3}.f3_2[0]`, 2], +].flatMap(([name, ssn, page]) => [ + { + kind: "text" as const, + domainKey: "nameShownOnForm1040", + pdfField: String(name), + includeWhen: onPage(Number(page)), + }, + { + kind: "text" as const, + domainKey: "primarySSN", + pdfField: String(ssn), + includeWhen: onPage(Number(page)), + }, +]); + +const TEMPLATE = { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040se--dft.pdf", + pdfSha256: "0bee231f1ecb2470fb884b1f9cee7707eddee8994ce479fc563850b2dd3c5aed", + formPages: [1, 2, 3], +}; + +const base = scheduleEPdfFor(2026); +const draft = onTy2026Template( + { ...base, pageIndices: scheduleEPages, filerFields: undefined }, + TEMPLATE, + rename, + ROWS.map((_, index) => `property_${index}_address`), + addressFields, +); + +type Projected = Record; +type ScheduleEProperty = z.infer["schedule_es"][number]; + +function splitAddresses( + copy: Projected, + properties: ReadonlyArray, +): Projected { + return { + ...copy, + ...Object.fromEntries( + properties.flatMap((item, index) => + item.property_type === 6 ? [] : [ + [`ty2026_property_${index}_street`, item.street_address], + [`ty2026_property_${index}_city`, item.city], + [`ty2026_property_${index}_state`, item.state], + [`ty2026_property_${index}_zip`, item.zip], + ] + ), + ), + }; +} + +export const scheduleEPdf2026: PdfFormDescriptor = withoutStatements( + { + ...draft, + filerFields, + projectFields(raw, allPending) { + const projected = base.projectFields?.(raw, allPending) ?? raw; + if (Number(projected.nonpassive_activity_amount ?? 0) !== 0) { + throw new Error( + "TY2026 PDF Schedule E nonpassive activity notation is not mapped; export MeF XML", + ); + } + const items = Object.keys(raw).length === 0 + ? [] + : inputSchema.parse(raw).schedule_es; + const continuations = projected.partIContinuationPages; + return { + ...splitAddresses(projected, items.slice(0, 3)), + ...(Array.isArray(continuations) + ? { + partIContinuationPages: continuations.map((copy, page) => + splitAddresses( + copy as Projected, + items.slice(3 + page * 3, 6 + page * 3), + ) + ), + } + : {}), + }; + }, + decoratePages: undefined, + }, + "Schedule E", +); diff --git a/scripts/testing/year-pinned-baseline.json b/scripts/testing/year-pinned-baseline.json index 583caf80c..bc11bc9f3 100644 --- a/scripts/testing/year-pinned-baseline.json +++ b/scripts/testing/year-pinned-baseline.json @@ -739,21 +739,11 @@ "pattern": "year-identifier", "count": 3 }, - { - "path": "forms/f1040/2025/pdf/forms/deductions/additional/schedule1a/schedule1a.ts", - "pattern": "year-value", - "count": 5 - }, { "path": "forms/f1040/2025/pdf/forms/deductions/business/f8995a_aggregation_statement.ts", "pattern": "year-identifier", "count": 1 }, - { - "path": "forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/2025/pdf/forms/general/return-assembly/f1040.ts", "pattern": "year-identifier",