diff --git a/cli/commands/export.ts b/cli/commands/export.ts index b347fbc2a..059f5f541 100644 --- a/cli/commands/export.ts +++ b/cli/commands/export.ts @@ -253,6 +253,8 @@ export async function exportMefCommand( export type ExportPdfArgs = ExportReturnArgs & { /** Output file path. Defaults to //export.pdf */ readonly outputPath?: string; + /** Receives the form year's PDF notice, if it has one. */ + readonly notice?: (message: string) => void; }; export async function exportPdfCommand( @@ -283,6 +285,7 @@ export async function exportPdfCommand( const outPath = args.outputPath ?? join(args.baseDir, args.returnId, "export.pdf"); await writeFile(outPath, outputBytes); + if (def.pdfNotice) args.notice?.(def.pdfNotice); return outPath; } diff --git a/cli/main.ts b/cli/main.ts index 110612e71..fa6d8eb7f 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -443,6 +443,7 @@ const COMMANDS: readonly CommandDef[] = [ force, draft, outputPath, + notice: (message) => console.log(message), }); console.log(`PDF written to ${writtenPath}`); }); diff --git a/core/types/form-definition.ts b/core/types/form-definition.ts index b9187cd85..9646e1f88 100644 --- a/core/types/form-definition.ts +++ b/core/types/form-definition.ts @@ -29,6 +29,8 @@ export interface FormDefinition { readonly formType: string; // e.g. "f1040" readonly taxYear: number; // e.g. 2025 readonly mefSchemaVersion: string; // e.g. "2025v3.0" + /** Shown with every PDF export, e.g. that it prints on IRS draft forms. */ + readonly pdfNotice?: string; readonly inputNodes: readonly InputNodeEntry[]; readonly registry: Record; /** Single form-owned execution entrypoint for validated source inputs. */ diff --git a/docs/architecture/STRUCTURE.md b/docs/architecture/STRUCTURE.md index 3bca41fc2..ed82b7bbd 100644 --- a/docs/architecture/STRUCTURE.md +++ b/docs/architecture/STRUCTURE.md @@ -27,7 +27,8 @@ Public entrypoints are `mod.ts`, `catalog.ts`, `cli/main.ts`, and `forms/f1040/2 │ │ ├── forms/ # Printed form projections by tax domain │ │ └── reviews/ # Retained review fixtures and contracts │ ├── 2026/ -│ │ └── index.ts / config.ts # TY2026 entrypoint: 2025 graph, inputs and builders, 2026 context and schema version +│ │ ├── index.ts / config.ts # TY2026 entrypoint: 2025 graph, inputs and builders, 2026 context and schema version +│ │ └── pdf/ # 2025 PDF projections retargeted to the IRS 2026 draft forms │ ├── nodes/ │ │ ├── config/ # Tax-year routing │ │ ├── inputs/
//
/ # Validated source nodes diff --git a/forms/f1040/2025/pdf/builder.ts b/forms/f1040/2025/pdf/builder.ts index e239544fa..4a16e8b53 100644 --- a/forms/f1040/2025/pdf/builder.ts +++ b/forms/f1040/2025/pdf/builder.ts @@ -146,6 +146,19 @@ import { } from "../../nodes/intermediate/forms/credits/individual/form8839/public_source.ts"; import { mefTaxYear } from "../mef/support/year-config.ts"; +/** The printed form set and return year a packet is rendered for. */ +export interface PdfBuildOptions { + readonly descriptors: ReadonlyArray; + readonly taxYear: number; + /** Recorded as the packet's PDF Subject. */ + readonly subject?: string; +} + +const DEFAULT_PDF_BUILD: PdfBuildOptions = { + descriptors: ALL_PDF_FORMS, + taxYear: mefTaxYear(), +}; + /** Diagnostic origin of a page in a prepared filled-PDF review packet. */ export interface PdfPageOrigin { readonly pageNumber: number; @@ -577,6 +590,7 @@ async function buildPdfBytesInternal( preparedBundleInput?: MefBundle, pageOrigins?: PdfPageOrigin[], paperOnly4972 = false, + options: PdfBuildOptions = DEFAULT_PDF_BUILD, ): Promise { // Preserve the authenticated disclosure identity and XML throughout awaits. const preparedBundle = preparedBundleInput @@ -592,11 +606,11 @@ async function buildPdfBytesInternal( pending, (inputs) => executeComposedSourceReturn(inputs, { - taxYear: mefTaxYear(), + taxYear: options.taxYear, formType: "f1040", }), ); - assertSingleFarmQbiReturn(normalized, mefTaxYear()); + assertSingleFarmQbiReturn(normalized, options.taxYear); if (normalized.f1040) { assertF1040FinalHeader(normalized.f1040, filer); assertLine1hSupportedSource(normalized.f1040, 2025, normalized, filer); @@ -609,7 +623,7 @@ async function buildPdfBytesInternal( assert1099RRecipientOwner(normalized.f1099r, filer, 2025); assertPositiveW2GRecipient(normalized.w2g, filer, 2025); assertNecWithholdingRecipient(normalized.f1099nec, filer); - assert1099BRecipientOwner(normalized.f1099b, filer, mefTaxYear()); + assert1099BRecipientOwner(normalized.f1099b, filer, options.taxYear); assertNoRepeatedBrokerSaleSources(normalized.f1099b, normalized.f8949); assertCapitalSaleSourceRows(normalized, 2025); assertPatrIssuedCopies(normalized.f1099patr); @@ -619,18 +633,18 @@ async function buildPdfBytesInternal( normalized, filer, preparedBundle?.retainedSourceDocuments !== undefined, - mefTaxYear(), + options.taxYear, ); await assertForm4852RetainedEvidence( normalized, filer, preparedBundle?.retainedSourceDocuments ?? [], - mefTaxYear(), + options.taxYear, ); await assertForm8874CdeStatusEvidence( normalized, preparedBundle?.retainedSourceDocuments ?? [], - mefTaxYear(), + options.taxYear, ); reconcileForm8606RothInventories(normalized, filer); reconcileForm8606RothActivity(normalized, filer); @@ -646,7 +660,7 @@ async function buildPdfBytesInternal( assertTaxExemptInterestSource(normalized); assertBusinessSchedule1Amounts(normalized); assertOwned7203RequiredCopies(normalized, { - taxYear: mefTaxYear(), + taxYear: options.taxYear, formType: "f1040", }); assertHsaExcessRequiredCopy(normalized); @@ -689,7 +703,7 @@ async function buildPdfBytesInternal( assertSchedule1Form8621Source({ ...normalized, form8949: pending.form8949, - }, { taxYear: mefTaxYear(), formType: "f1040" }); + }, { taxYear: options.taxYear, formType: "f1040" }); assertSchedule1NecSourceIdentity(normalized, filer); assertSchedule1KSourceIdentity(normalized, filer); assertScheduleFFarmSourceIdentity(normalized, filer); @@ -722,13 +736,13 @@ async function buildPdfBytesInternal( normalized.f8835, filer, [], - mefTaxYear(), + options.taxYear, ); await assertForm8835DomesticStatements( normalized.f8835, filer, [], - mefTaxYear(), + options.taxYear, ); await assertForm8835IncreaseStatements(normalized.f8835, filer, []); } @@ -746,13 +760,13 @@ async function buildPdfBytesInternal( normalized.f8835, filer, preparedBundle.attachments, - mefTaxYear(), + options.taxYear, ); await assertForm8835DomesticStatements( normalized.f8835, filer, preparedBundle.attachments, - mefTaxYear(), + options.taxYear, ); await assertForm8835IncreaseStatements( normalized.f8835, @@ -771,7 +785,7 @@ async function buildPdfBytesInternal( preparedBundle.pending, filer, preparedBundle.attachments, - mefTaxYear(), + options.taxYear, ); if ( (preparedBundle.form3800Parts === undefined) !== @@ -906,12 +920,12 @@ async function buildPdfBytesInternal( normalized, "pdf", preparedBundle?.form8886Packets, - mefTaxYear(), + options.taxYear, ); const merged = await PDFDocument.create({ updateMetadata: false }); const copyCounts = new Map(); - for (const registeredDescriptor of ALL_PDF_FORMS) { + for (const registeredDescriptor of options.descriptors) { const descriptor = paperOnly4972 && registeredDescriptor.pendingKey === "form4972" ? form4972PaperPdf @@ -1095,6 +1109,7 @@ async function buildPdfBytesInternal( "No PDF forms were generated — return may have no computed data.", ); } + if (options.subject !== undefined) merged.setSubject(options.subject); return merged.save(); } @@ -1105,6 +1120,7 @@ export function buildPdfBytes( cacheDir = ".pdf-cache", preparedBundle?: MefBundle, pageOrigins?: PdfPageOrigin[], + options: PdfBuildOptions = DEFAULT_PDF_BUILD, ): Promise { return buildPdfBytesInternal( pending, @@ -1112,6 +1128,8 @@ export function buildPdfBytes( cacheDir, preparedBundle, pageOrigins, + false, + options, ); } diff --git a/forms/f1040/2025/pdf/forms/general/return-assembly/f1040.ts b/forms/f1040/2025/pdf/forms/general/return-assembly/f1040.ts index 220689763..155b2e31b 100644 --- a/forms/f1040/2025/pdf/forms/general/return-assembly/f1040.ts +++ b/forms/f1040/2025/pdf/forms/general/return-assembly/f1040.ts @@ -703,6 +703,7 @@ const fields: ReadonlyArray = [ function line1hType( fields: Record, allPending: Record>, + taxYear: number, ): | "FEC" | "EXCESS DEFERRALS" @@ -738,7 +739,7 @@ function line1hType( if (allPending.w2 !== undefined) { const excess = codeDExcessDeferral( w2InputSchema.parse(allPending.w2).w2s, - mefTaxYear(), + taxYear, ); if (excess.amount > 0) { sources.push({ type: "EXCESS DEFERRALS", amount: excess.amount }); @@ -763,7 +764,7 @@ function line1hType( items.length ) { throw new Error( - "Form 1040 PDF line 1h corrective distributions need identified 2025 Form 1099-R sources", + `Form 1040 PDF line 1h corrective distributions need identified ${taxYear} Form 1099-R sources`, ); } sources.push({ @@ -775,7 +776,7 @@ function line1hType( }); } } - const disability = retainedDisabilityPension(allPending, 2025); + const disability = retainedDisabilityPension(allPending, taxYear); if (disability > 0) { sources.push({ type: "DISABILITY PENSION", amount: disability }); } @@ -808,14 +809,16 @@ function line1hType( return mixedFecExcess ? "FEC + EXCESS DEFERRALS" : sources[0].type; } -export const irs1040Pdf: PdfFormDescriptor = { +/** Form 1040 projection and guards for the return year; the 2025 layout. */ +export function form1040PdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "f1040", // Year-pinned: /pub/irs-pdf/f1040.pdf silently changes revision each filing // season; this module is the 2025 form and must always fetch the 2025 PDF. pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040--2025.pdf", projectFields(fields, allPending) { - assertHohQualifyingChildSource(fields, allPending, 2025); - assertQssNonclaimedChildSource(fields, allPending, 2025); + assertHohQualifyingChildSource(fields, allPending, taxYear); + assertQssNonclaimedChildSource(fields, allPending, taxYear); const hasForm8888 = allPending.f8888 !== undefined; if ( hasForm8888 && @@ -832,7 +835,7 @@ export const irs1040Pdf: PdfFormDescriptor = { assertPositive1099MOwner(fields, allPending); assertPositive1099NecOwner(fields, allPending); assertPositive1099PatrOwner(fields, allPending); - assertJointDependentRefundSource(fields, allPending, 2025); + assertJointDependentRefundSource(fields, allPending, taxYear); assertNoUnsupportedDeceasedReturn( fields, allPending?.general as Record | undefined, @@ -840,8 +843,8 @@ export const irs1040Pdf: PdfFormDescriptor = { assertLine36EstimatedTaxSource(fields, allPending); assertPresidentialCampaignSource(fields, allPending); assertReturnWideArithmetic(fields); - const printLine1hType = line1hType(fields, allPending); - assertLine1hSupportedSource(fields, 2025, allPending); + const printLine1hType = line1hType(fields, allPending, taxYear); + assertLine1hSupportedSource(fields, taxYear, allPending); assertDirectCapitalGainDistributionSource(fields, allPending); const standard = fields.line12a_standard_deduction; const itemized = fields.line12e_itemized_deductions; @@ -861,7 +864,7 @@ export const irs1040Pdf: PdfFormDescriptor = { const printFormerSpouseEstimatedTaxSsn = assertEstimatedPaymentLine26( fields, allPending, - 2025, + taxYear, ); assertOtherFormsWithholding(fields, allPending); assertReturnScheduleJoins(fields, allPending); @@ -903,7 +906,7 @@ export const irs1040Pdf: PdfFormDescriptor = { (dep.ssn ?? dep.itin ?? dep.atin)?.replaceAll("-", ""); printedDependents[`dependent_${i}_relationship`] = dep.relationship; printedDependents[`dependent_${i}_home`] = - dependentLivedWithFilerOverHalfYear(dep, mefTaxYear()); + dependentLivedWithFilerOverHalfYear(dep, taxYear); printedDependents[`dependent_${i}_home_us`] = dep.lived_in_us_over_half_year; printedDependents[`dependent_${i}_full_time_student`] = @@ -937,7 +940,7 @@ export const irs1040Pdf: PdfFormDescriptor = { "Form 1040 PDF line 5c needs valid Form 1099-R source facts", ); } - assertIraRolloverEvidence(source.data.f1099rs, 2025); + assertIraRolloverEvidence(source.data.f1099rs, taxYear); if (rollover !== source.data.f1099rs.some(isPensionDirectRollover)) { throw new Error( "Form 1040 PDF line 5c rollover does not match the payer-reported Form 1099-R code G", @@ -1297,4 +1300,7 @@ export const irs1040Pdf: PdfFormDescriptor = { pdfField: "topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_27[0]", }, ], -}; + }; +} + +export const irs1040Pdf: PdfFormDescriptor = form1040PdfFor(mefTaxYear()); diff --git a/forms/f1040/2025/pdf/forms/general/return-assembly/schedule3.ts b/forms/f1040/2025/pdf/forms/general/return-assembly/schedule3.ts index 87cf73ccd..78fea62f1 100644 --- a/forms/f1040/2025/pdf/forms/general/return-assembly/schedule3.ts +++ b/forms/f1040/2025/pdf/forms/general/return-assembly/schedule3.ts @@ -1,3 +1,4 @@ +import { mefTaxYear } from "../../../../mef/support/year-config.ts"; import type { PdfFieldEntry, PdfFormDescriptor } from "../../../review-support/form-descriptor.ts"; import { scheduleR } from "../../../../mef/forms/credits/elderly-disabled/schedule_r.ts"; import { assertSchedule3Line8Join } from "../../../../domains/credits/individual/schedule3_line8_join.ts"; @@ -176,7 +177,9 @@ const fields: ReadonlyArray = [ }, ]; -export const schedule3Pdf: PdfFormDescriptor = { +/** Schedule 3 projection and guards for the return year; the 2025 layout. */ +export function schedule3PdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "schedule3", pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040s3--2025.pdf", fields, @@ -192,7 +195,7 @@ export const schedule3Pdf: PdfFormDescriptor = { ? { ...fields, line6l_form8978_credit: line6l } : fields; assertSchedule3PrintedTotals(projected, allPending); - assertSchedule3PaymentSources(projected, allPending, 2025); + assertSchedule3PaymentSources(projected, allPending, taxYear); assertSchedule3Line6jSource( fields.line6j_alt_fuel_vehicle_refueling, allPending.f8911, @@ -222,4 +225,7 @@ export const schedule3Pdf: PdfFormDescriptor = { pdfField: "topmostSubform[0].Page1[0].f1_02[0]", }, ], -}; + }; +} + +export const schedule3Pdf: PdfFormDescriptor = schedule3PdfFor(mefTaxYear()); diff --git a/forms/f1040/2026/README.md b/forms/f1040/2026/README.md index ab744b838..e886c0730 100644 --- a/forms/f1040/2026/README.md +++ b/forms/f1040/2026/README.md @@ -4,7 +4,16 @@ `ctx.taxYear = 2026`, on the 2025 registry, staged return pipeline (`2025/return-processing/execute_return.ts`), MeF builder and pending normalization with the 2026v1.0 schema. It accepts every input TY2025 accepts; -there is no allowlist. PDF output is refused. +there is no allowlist. + +PDF output (`pdf/builder.ts`) prints Form 1040 and Schedules 1, 2 and 3 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 and renames only the fields the 2026 form moved. +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. Year differences live in two places, never in a 2026 copy of a node: diff --git a/forms/f1040/2026/index.test.ts b/forms/f1040/2026/index.test.ts index 5660bdcb9..d77ba3453 100644 --- a/forms/f1040/2026/index.test.ts +++ b/forms/f1040/2026/index.test.ts @@ -20,14 +20,16 @@ test("TY2026 accepts every input TY2025 accepts, on the same registry", () => { assertStrictEquals(f1040_2026.registry, f1040_2025.registry); }); -test("TY2026 PDF output is refused rather than printed on 2025 forms", async () => { +test("TY2026 PDF prints on IRS 2026 forms and says they are drafts", async () => { let message = ""; try { await f1040_2026.buildPdfBytes({}, undefined); } catch (error) { message = String(error); } - assertEquals(message.includes("TY2026 PDF"), true); + assertEquals(message.includes("No PDF forms were generated"), true); + assertEquals(f1040_2026.pdfNotice?.includes("IRS draft forms"), true); + assertEquals(f1040_2025.pdfNotice, undefined); }); const sample = (id: string) => { diff --git a/forms/f1040/2026/index.ts b/forms/f1040/2026/index.ts index 76fa6be75..72f66e3e4 100644 --- a/forms/f1040/2026/index.ts +++ b/forms/f1040/2026/index.ts @@ -8,6 +8,8 @@ import type { MefFormsPending } from "../2025/mef/types.ts"; import { assertF1040FinalHeader } from "../2025/domains/general/return-assembly/filer-source-reconciliation.ts"; import { executeF1040Return } from "../2025/return-processing/execute_return.ts"; import { F1040_2026_CONFIG } from "./config.ts"; +import { buildPdfBytes2026 } from "./pdf/builder.ts"; +import { TY2026_DRAFT_NOTICE } from "./pdf/draft-template.ts"; const context = { taxYear: F1040_2026_CONFIG.taxYear, formType: "f1040" }; @@ -17,15 +19,12 @@ function executeReturn(inputs: Record) { return executeF1040Return(inputs, context); } -function noPdf(): never { - throw new Error("TY2026 PDF forms are not available yet; export MeF XML"); -} - export const f1040_2026: FormDefinition = { ...F1040_2026_CONFIG, inputNodes, registry, executeReturn, + pdfNotice: TY2026_DRAFT_NOTICE, prepareReturn: async ( pendingInput, filerInput, @@ -50,7 +49,11 @@ export const f1040_2026: FormDefinition = { year: F1040_2026_CONFIG.taxYear, returnType: "1040", }); - return { bundle, renderPdf: () => Promise.resolve(noPdf()) }; + return { + bundle, + renderPdf: () => + buildPdfBytes2026(bundle.pending, filer, ".pdf-cache", bundle), + }; }, buildMefXml: (pending, filer) => buildMefXml( @@ -60,7 +63,7 @@ export const f1040_2026: FormDefinition = { F1040_2026_CONFIG.taxYear, "1040", ), - buildPdfBytes: () => Promise.resolve(noPdf()), + buildPdfBytes: (pending, filer) => buildPdfBytes2026(pending, filer), buildPending: (pending: Record) => buildPending(pending) as Record, }; diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts new file mode 100644 index 000000000..2beea9937 --- /dev/null +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -0,0 +1,123 @@ +import { test } from "bun:test"; +import { assertEquals, assertMatch, assertRejects } from "@std/assert"; +import { PDFDocument } from "pdf-lib"; +import * as fs from "node:fs/promises"; +import * as sys from "../../../../scripts/support/sys.ts"; +import { signed } from "../../../../scripts/testing/mef.ts"; +import { pdfReviewFixtures } from "../../2025/pdf/review-fixtures.ts"; +import { extractFilerIdentity } from "../../mef/filer.ts"; +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 { loadPdfTemplate } from "../../2025/pdf/support/template-cache.ts"; + +const fixture = (id: string) => { + const row = pdfReviewFixtures.find((candidate) => candidate.id === id); + if (!row) throw new Error(`missing sample ${id}`); + return row.inputs as Record; +}; + +async function render(inputs: Record) { + const result = f1040_2026.executeReturn(signed(inputs)); + const pending = result.pending as Record>; + const filer = extractFilerIdentity(pending.f1040 ?? {}); + return await f1040_2026.buildPdfBytes( + f1040_2026.buildPending(pending), + filer, + ); +} + +async function text(bytes: Uint8Array): Promise { + const path = await sys.makeTempFile({ suffix: ".pdf" }); + try { + await fs.writeFile(path, bytes); + const out = await new sys.Command("pdftotext", { + args: ["-layout", path, "-"], + stdout: "piped", + stderr: "piped", + }).output(); + assertEquals(out.success, true, new TextDecoder().decode(out.stderr)); + return new TextDecoder().decode(out.stdout).replace(/[ \t]+/g, " "); + } finally { + await sys.remove(path); + } +} + +test("TY2026 PDF prints Form 1040 and Schedule 3 on the 2026 draft lines", async () => { + const bytes = await render( + fixture("single-two-employers-excess-social-security"), + ); + const doc = await PDFDocument.load(bytes, { updateMetadata: false }); + assertEquals(doc.getPageCount(), 3); + assertEquals(doc.getSubject(), TY2026_DRAFT_NOTICE); + const printed = await text(bytes); + assertMatch(printed, /Individual Income Tax Return 2026/); + assertMatch(printed, /DRAFT/); + assertMatch(printed, /1a Total amount from Form\(s\) W-2[^\n]* 1a 200000/); + assertMatch(printed, /12e Standard deduction[^\n]* 12e 16100/); + assertMatch(printed, /24a Add lines 22 and 23[^\n]* 24a 36734/); + assertMatch(printed, /24c Add lines 24a and 24b[^\n]* 24c 36734/); + assertMatch(printed, /32c Subtract line 32b[^\n]* 32c 961/); + assertMatch(printed, /11 Excess social security[^\n]* 11 961/); + assertMatch(printed, /15 Add lines 9 through 12 and 14[^\n]* 15 961/); +}); + +test("TY2026 PDF prints Schedule 1 on the 2026 draft", async () => { + const printed = await text( + await render(fixture("single-mixed-box8-rtaa-taxable-grant-line8z")), + ); + assertMatch(printed, /SCHEDULE 1[\s\S]*2026/); + assertMatch(printed, /8 Additional income from Schedule 1, line 10[^\n]* 8 1750/); +}); + +test("TY2026 PDF moves W-2 uncollected tax to Schedule 2 line 17c", async () => { + const inputs = fixture("single-two-employers-excess-social-security"); + const [first] = inputs.w2 as Array>; + const printed = await text( + await render({ + ...inputs, + w2: [{ + ...first, + box12_entries: [{ code: Box12Code.A, amount: 300 }], + }], + }), + ); + assertMatch(printed, /SCHEDULE 2[\s\S]*2026/); + assertMatch(printed, /17c Uncollected social security[\s\S]*?17c 300/); + assertMatch(printed, /17d Total other employment taxes[^\n]* 17d 300/); + assertMatch(printed, /20 Total additional employment[^\n]* 20 300/); + assertMatch(printed, /\n 21 [^\n]*\n[^\n]* 21 300/); + assertMatch(printed, /23 Additional taxes[^\n]* 23 300/); +}); + +test("TY2026 PDF refuses, by name, a form with no TY2026 mapping", async () => { + await assertRejects( + () => render(fixture("single-form2441-child-care-credit")), + Error, + "TY2026 PDF export needs form2441", + ); +}); + +test("every TY2026 mapped field exists on its pinned 2026 template", async () => { + const forms = await ty2026PdfForms(); + for (const key of TY2026_PDF_MAPPED_FORMS) { + const descriptor = forms.find((row) => row.pendingKey === key)!; + const doc = await PDFDocument.load( + await loadPdfTemplate( + descriptor.pdfUrl, + ".pdf-cache", + descriptor.pdfSha256, + ), + { ignoreEncryption: true, updateMetadata: false }, + ); + const names = new Set(doc.getForm().getFields().map((f) => f.getName())); + const missing = [...descriptor.fields, ...(descriptor.filerFields ?? [])] + .flatMap((entry) => [ + entry.pdfField, + ...("extraPdfFields" in entry ? entry.extraPdfFields ?? [] : []), + ]) + .filter((name) => !names.has(name)); + assertEquals(missing, [], key); + } +}); diff --git a/forms/f1040/2026/pdf/builder.ts b/forms/f1040/2026/pdf/builder.ts new file mode 100644 index 000000000..b9e945963 --- /dev/null +++ b/forms/f1040/2026/pdf/builder.ts @@ -0,0 +1,51 @@ +import type { FilerIdentity } from "../../mef/header.ts"; +import type { MefBundle } from "../../2025/mef/builder.ts"; +import type { PdfPageOrigin } from "../../2025/pdf/builder.ts"; +import type { PdfFormDescriptor } from "../../2025/pdf/review-support/form-descriptor.ts"; +import { refusedOnTy2026, TY2026_DRAFT_NOTICE } from "./draft-template.ts"; +import { irs1040Pdf2026 } from "./forms/general/return-assembly/f1040.ts"; +import { schedule1Pdf2026 } from "./forms/general/return-assembly/schedule1.ts"; +import { schedule3Pdf2026 } from "./forms/general/return-assembly/schedule3.ts"; +import { schedule2Pdf2026 } from "./forms/taxes/other/schedule2.ts"; + +const MAPPED: ReadonlyMap = new Map( + [irs1040Pdf2026, schedule1Pdf2026, schedule2Pdf2026, schedule3Pdf2026].map( + (descriptor) => [descriptor.pendingKey, descriptor], + ), +); + +/** The 2025 print order; forms without a TY2026 mapping refuse when needed. */ +export async function ty2026PdfForms(): Promise< + ReadonlyArray +> { + // Loaded on use: a static import of the 2025 PDF modules here makes the + // compiled binary emit an undefined async-init helper (Bun 1.4.2). + const { ALL_PDF_FORMS } = await import("../../2025/pdf/forms/index.ts"); + return ALL_PDF_FORMS.map((descriptor) => + MAPPED.get(descriptor.pendingKey) ?? refusedOnTy2026(descriptor) + ); +} + +export const TY2026_PDF_MAPPED_FORMS: readonly string[] = [...MAPPED.keys()]; + +export async function buildPdfBytes2026( + pending: Record, + filer: FilerIdentity | undefined, + cacheDir = ".pdf-cache", + preparedBundle?: MefBundle, + pageOrigins?: PdfPageOrigin[], +): Promise { + const { buildPdfBytes } = await import("../../2025/pdf/builder.ts"); + return await buildPdfBytes( + pending, + filer, + cacheDir, + preparedBundle, + pageOrigins, + { + descriptors: await ty2026PdfForms(), + taxYear: 2026, + subject: TY2026_DRAFT_NOTICE, + }, + ); +} diff --git a/forms/f1040/2026/pdf/draft-template.ts b/forms/f1040/2026/pdf/draft-template.ts new file mode 100644 index 000000000..ad31ebc5f --- /dev/null +++ b/forms/f1040/2026/pdf/draft-template.ts @@ -0,0 +1,82 @@ +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../2025/pdf/review-support/form-descriptor.ts"; + +/** A pinned IRS TY2026 form PDF and the pages that carry the form. */ +export interface Ty2026Template { + readonly pdfUrl: string; + readonly pdfSha256: string; + /** Draft PDFs open with an IRS cover sheet that is not part of the form. */ + readonly formPages: readonly number[]; +} + +export const TY2026_DRAFT_NOTICE = + "TY2026 PDF uses IRS draft forms (DRAFT — DO NOT FILE); file the MeF XML"; + +function renameEntry( + entry: PdfFieldEntry, + rename: Readonly>, +): PdfFieldEntry { + const name = (field: string) => rename[field] ?? field; + return "extraPdfFields" in entry && entry.extraPdfFields + ? { + ...entry, + pdfField: name(entry.pdfField), + extraPdfFields: entry.extraPdfFields.map(name), + } + : { ...entry, pdfField: name(entry.pdfField) }; +} + +/** + * The 2025 projection printed on a 2026 template. `rename` maps a 2025 field + * name to its 2026 name; `drop` removes domain keys the 2026 form has no box for. + */ +export function onTy2026Template( + base: PdfFormDescriptor, + template: Ty2026Template, + rename: Readonly>, + drop: readonly string[] = [], + added: readonly PdfFieldEntry[] = [], +): PdfFormDescriptor { + const keep = (entry: PdfFieldEntry) => !drop.includes(entry.domainKey); + return { + ...base, + pdfUrl: template.pdfUrl, + pdfSha256: template.pdfSha256, + pageIndices: () => template.formPages, + fields: [ + ...base.fields.filter(keep).map((entry) => renameEntry(entry, rename)), + ...added, + ], + filerFields: base.filerFields?.map((entry) => ({ + ...entry, + ...renameEntry(entry, rename), + })), + }; +} + +/** Throws when the return needs a form that has no TY2026 PDF mapping yet. */ +export function refusedOnTy2026(base: PdfFormDescriptor): PdfFormDescriptor { + const form = base.printedFormKey ?? base.pendingKey; + const pdf = base.pdfUrl.split("/").pop() ?? base.pdfUrl; + return { + pendingKey: base.pendingKey, + printedFormKey: base.printedFormKey, + pdfUrl: base.pdfUrl, + presenceKey: base.presenceKey, + projectFields: base.projectFields, + instances: base.instances, + fields: base.fields, + rows: base.rows, + includeWhenNoMappedData: base.includeWhenNoMappedData, + includeWhen(fields, allPending) { + if (base.includeWhen && !base.includeWhen(fields, allPending)) { + return false; + } + throw new Error( + `TY2026 PDF export needs ${form} (2025 template ${pdf}), which has no TY2026 PDF mapping yet; export MeF XML`, + ); + }, + }; +} diff --git a/forms/f1040/2026/pdf/forms/general/return-assembly/f1040.ts b/forms/f1040/2026/pdf/forms/general/return-assembly/f1040.ts new file mode 100644 index 000000000..7e5aadaf9 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/general/return-assembly/f1040.ts @@ -0,0 +1,250 @@ +import { form1040PdfFor } from "../../../../../2025/pdf/forms/general/return-assembly/f1040.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Form 1040 early-release draft (Created 4/27/26), page 1 is the +// IRS cover sheet. Against the 2025 form: two citizenship questions add +// c1_11/c1_12, so later page-1 boxes move +2; line 12f (f2_03) is new; lines +// 13a and 13b swap (13a is Schedule 1-A, 13b QBI); line 24 becomes 24a/24b/24c +// and line 32 becomes 32a/32b/32c; the line 28 ACTC opt-out box is gone. +const RENAME: Readonly> = { + "topmostSubform[0].Page1[0].Checkbox_ReadOrder[0].c1_8[0]": + "topmostSubform[0].Page1[0].c1_8[0]", + "topmostSubform[0].Page1[0].Checkbox_ReadOrder[0].c1_8[1]": + "topmostSubform[0].Page1[0].c1_8[1]", + "topmostSubform[0].Page1[0].Checkbox_ReadOrder[0].c1_8[2]": + "topmostSubform[0].Page1[0].c1_8[2]", + "topmostSubform[0].Page1[0].c1_8[0]": + "topmostSubform[0].Page1[0].HOH-QSS_ReadOrder[0].c1_8[0]", + "topmostSubform[0].Page1[0].c1_8[1]": + "topmostSubform[0].Page1[0].HOH-QSS_ReadOrder[0].c1_8[1]", + "topmostSubform[0].Page1[0].c1_9[0]": + "topmostSubform[0].Page1[0].Nonresident_ReadOrder[0].c1_9[0]", + "topmostSubform[0].Page1[0].Dependents_ReadOrder[0].c1_11[0]": + "topmostSubform[0].Page1[0].Dependents_ReadOrder[0].c1_13[0]", + "topmostSubform[0].Page1[0].c1_32[0]": + "topmostSubform[0].Page1[0].c1_34[0]", + "topmostSubform[0].Page1[0].Checkbox_ReadOrder[0].f1_28[0]": + "topmostSubform[0].Page1[0].f1_28[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent1[0].c1_12[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent1[0].c1_14[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent1[0].c1_13[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent1[0].c1_15[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent1[0].c1_20[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent1[0].c1_22[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent1[0].c1_21[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent1[0].c1_23[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent1[0].c1_28[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent1[0].c1_30[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent1[0].c1_28[1]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent1[0].c1_30[1]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent2[0].c1_14[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent2[0].c1_16[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent2[0].c1_15[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent2[0].c1_17[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent2[0].c1_22[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent2[0].c1_24[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent2[0].c1_23[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent2[0].c1_25[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent2[0].c1_29[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent2[0].c1_31[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent2[0].c1_29[1]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent2[0].c1_31[1]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent3[0].c1_16[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent3[0].c1_18[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent3[0].c1_17[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent3[0].c1_19[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent3[0].c1_24[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent3[0].c1_26[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent3[0].c1_25[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent3[0].c1_27[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent3[0].c1_30[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent3[0].c1_32[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent3[0].c1_30[1]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent3[0].c1_32[1]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent4[0].c1_18[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent4[0].c1_20[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent4[0].c1_19[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row5[0].Dependent4[0].c1_21[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent4[0].c1_26[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent4[0].c1_28[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent4[0].c1_27[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row6[0].Dependent4[0].c1_29[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent4[0].c1_31[0]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent4[0].c1_33[0]", + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent4[0].c1_31[1]": + "topmostSubform[0].Page1[0].Table_Dependents[0].Row7[0].Dependent4[0].c1_33[1]", + "topmostSubform[0].Page1[0].c1_33[0]": + "topmostSubform[0].Page1[0].c1_35[0]", + "topmostSubform[0].Page1[0].c1_34[0]": + "topmostSubform[0].Page1[0].c1_36[0]", + "topmostSubform[0].Page1[0].c1_35[0]": + "topmostSubform[0].Page1[0].c1_37[0]", + "topmostSubform[0].Page1[0].c1_36[0]": + "topmostSubform[0].Page1[0].c1_38[0]", + "topmostSubform[0].Page1[0].c1_38[0]": + "topmostSubform[0].Page1[0].c1_40[0]", + "topmostSubform[0].Page1[0].c1_39[0]": + "topmostSubform[0].Page1[0].c1_41[0]", + "topmostSubform[0].Page1[0].c1_42[0]": + "topmostSubform[0].Page1[0].c1_44[0]", + "topmostSubform[0].Page1[0].c1_43[0]": + "topmostSubform[0].Page1[0].c1_45[0]", + "topmostSubform[0].Page1[0].c1_44[0]": + "topmostSubform[0].Page1[0].c1_46[0]", + "topmostSubform[0].Page2[0].f2_03[0]": + "topmostSubform[0].Page2[0].f2_05[0]", + "topmostSubform[0].Page2[0].f2_05[0]": + "topmostSubform[0].Page2[0].f2_06[0]", + "topmostSubform[0].Page2[0].f2_06[0]": + "topmostSubform[0].Page2[0].f2_07[0]", + "topmostSubform[0].Page2[0].f2_07[0]": + "topmostSubform[0].Page2[0].f2_08[0]", + "topmostSubform[0].Page2[0].f2_08[0]": + "topmostSubform[0].Page2[0].f2_09[0]", + "topmostSubform[0].Page2[0].f2_09[0]": + "topmostSubform[0].Page2[0].f2_10[0]", + "topmostSubform[0].Page2[0].f2_10[0]": + "topmostSubform[0].Page2[0].f2_11[0]", + "topmostSubform[0].Page2[0].f2_11[0]": + "topmostSubform[0].Page2[0].f2_12[0]", + "topmostSubform[0].Page2[0].f2_12[0]": + "topmostSubform[0].Page2[0].f2_13[0]", + "topmostSubform[0].Page2[0].f2_13[0]": + "topmostSubform[0].Page2[0].f2_14[0]", + "topmostSubform[0].Page2[0].f2_14[0]": + "topmostSubform[0].Page2[0].f2_15[0]", + "topmostSubform[0].Page2[0].f2_15[0]": + "topmostSubform[0].Page2[0].f2_16[0]", + "topmostSubform[0].Page2[0].f2_16[0]": + "topmostSubform[0].Page2[0].f2_17[0]", + "topmostSubform[0].Page2[0].f2_17[0]": + "topmostSubform[0].Page2[0].Line25_ReadOrder[0].f2_20[0]", + "topmostSubform[0].Page2[0].f2_18[0]": + "topmostSubform[0].Page2[0].f2_21[0]", + "topmostSubform[0].Page2[0].f2_19[0]": + "topmostSubform[0].Page2[0].f2_22[0]", + "topmostSubform[0].Page2[0].f2_20[0]": + "topmostSubform[0].Page2[0].f2_23[0]", + "topmostSubform[0].Page2[0].f2_21[0]": + "topmostSubform[0].Page2[0].f2_24[0]", + "topmostSubform[0].Page2[0].SSN_ReadOrder[0].f2_22[0]": + "topmostSubform[0].Page2[0].Estimated_ReadOrder[0].f2_25[0]", + "topmostSubform[0].Page2[0].f2_23[0]": + "topmostSubform[0].Page2[0].f2_26[0]", + "topmostSubform[0].Page2[0].f2_24[0]": + "topmostSubform[0].Page2[0].f2_27[0]", + "topmostSubform[0].Page2[0].f2_25[0]": + "topmostSubform[0].Page2[0].f2_28[0]", + "topmostSubform[0].Page2[0].f2_26[0]": + "topmostSubform[0].Page2[0].f2_29[0]", + "topmostSubform[0].Page2[0].f2_27[0]": + "topmostSubform[0].Page2[0].Line31-32_ReadOrder[0].f2_30[0]", + "topmostSubform[0].Page2[0].f2_28[0]": + "topmostSubform[0].Page2[0].Line31-32_ReadOrder[0].f2_31[0]", + "topmostSubform[0].Page2[0].f2_29[0]": + "topmostSubform[0].Page2[0].f2_34[0]", + "topmostSubform[0].Page2[0].f2_30[0]": + "topmostSubform[0].Page2[0].f2_35[0]", + "topmostSubform[0].Page2[0].f2_31[0]": + "topmostSubform[0].Page2[0].f2_36[0]", + "topmostSubform[0].Page2[0].f2_34[0]": + "topmostSubform[0].Page2[0].f2_39[0]", + "topmostSubform[0].Page2[0].c2_15[0]": + "topmostSubform[0].Page2[0].c2_14[0]", + "topmostSubform[0].Page2[0].f2_35[0]": + "topmostSubform[0].Page2[0].f2_40[0]", + "topmostSubform[0].Page2[0].f2_36[0]": + "topmostSubform[0].Page2[0].f2_41[0]", + "topmostSubform[0].Page2[0].RoutingNo[0].f2_32[0]": + "topmostSubform[0].Page2[0].RoutingNo[0].f2_37[0]", + "topmostSubform[0].Page2[0].c2_16[0]": + "topmostSubform[0].Page2[0].c2_15[0]", + "topmostSubform[0].Page2[0].c2_16[1]": + "topmostSubform[0].Page2[0].c2_15[1]", + "topmostSubform[0].Page2[0].AccountNo[0].f2_33[0]": + "topmostSubform[0].Page2[0].AccountNo[0].f2_38[0]", + "topmostSubform[0].Page2[0].f2_40[0]": + "topmostSubform[0].Page2[0].f2_45[0]", + "topmostSubform[0].Page2[0].f2_41[0]": + "topmostSubform[0].Page2[0].f2_46[0]", + "topmostSubform[0].Page2[0].f2_42[0]": + "topmostSubform[0].Page2[0].f2_47[0]", + "topmostSubform[0].Page2[0].f2_43[0]": + "topmostSubform[0].Page2[0].f2_48[0]", + "topmostSubform[0].Page2[0].f2_44[0]": + "topmostSubform[0].Page2[0].f2_49[0]", + "topmostSubform[0].Page2[0].f2_45[0]": + "topmostSubform[0].Page2[0].f2_50[0]",}; + +const PAGE2 = "topmostSubform[0].Page2[0]"; +const REFUNDABLE_LINES = [ + "line27_eitc", + "line28_actc", + "line29_refundable_aoc", + "line30_refundable_adoption", +] as const; + +function assertTy2026Printable(fields: Record): void { + const positive = (key: string) => + typeof fields[key] === "number" && (fields[key] as number) > 0; + const refundable = REFUNDABLE_LINES.filter(positive); + if (refundable.length > 0) { + throw new Error( + `TY2026 PDF Form 1040 ${ + refundable.join(", ") + } needs Schedule 3-A (line 32b), which is not modeled; export MeF XML`, + ); + } + if (fields.print_do_not_claim_actc === true) { + throw new Error("TY2026 draft Form 1040 has no ACTC opt-out box"); + } + if ( + typeof fields.print_form8814_line7a_note === "string" || + positive("form8621_1294_deferred_tax") + ) { + throw new Error( + "TY2026 PDF Form 1040 line 7a and line 24 notes are placed for the 2025 layout only", + ); + } +} + +const base = form1040PdfFor(2026); +const draft = onTy2026Template( + base, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040--dft.pdf", + pdfSha256: + "e044e765ace334c76bda7910bd3193fc813c7789d06340124359b885c98d58d0", + formPages: [1, 2], + }, + RENAME, + ["print_do_not_claim_actc"], + [{ + kind: "text", + domainKey: "line12f_nonitemizer_charitable", + pdfField: `${PAGE2}.f2_03[0]`, + }], +); + +const TOTALS: Readonly> = { + // Form 1062 (24b) and Schedule 3-A (32b) are not modeled, so 24c = 24a and + // 32c = 32a; refundable credits that would need Schedule 3-A are refused. + line24_total_tax: `${PAGE2}.f2_19[0]`, + line32_refundable_credits_total: `${PAGE2}.f2_33[0]`, +}; + +export const irs1040Pdf2026: PdfFormDescriptor = { + ...draft, + fields: draft.fields.map((entry) => + entry.kind === "text" && TOTALS[entry.domainKey] + ? { ...entry, extraPdfFields: [TOTALS[entry.domainKey]] } + : entry + ), + projectFields(fields, allPending) { + const projected = base.projectFields?.(fields, allPending) ?? fields; + assertTy2026Printable(projected); + return projected; + }, + decoratePages: undefined, +}; diff --git a/forms/f1040/2026/pdf/forms/general/return-assembly/schedule1.ts b/forms/f1040/2026/pdf/forms/general/return-assembly/schedule1.ts new file mode 100644 index 000000000..3f4fa1ed2 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/general/return-assembly/schedule1.ts @@ -0,0 +1,17 @@ +import { schedule1Pdf } from "../../../../../2025/pdf/forms/general/return-assembly/schedule1/schedule1.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Schedule 1 draft (page 1 is the IRS cover sheet). Every mapped +// field keeps its 2025 name and line; only the unmapped line 24 container +// was renamed. +export const schedule1Pdf2026: PdfFormDescriptor = onTy2026Template( + schedule1Pdf, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040s1--dft.pdf", + pdfSha256: + "a017a1b717d1c70ff8d831ef8b73c730e5b24f147c394c37f792035a9df22fad", + formPages: [1, 2], + }, + {}, +); diff --git a/forms/f1040/2026/pdf/forms/general/return-assembly/schedule3.ts b/forms/f1040/2026/pdf/forms/general/return-assembly/schedule3.ts new file mode 100644 index 000000000..8d6a57a1a --- /dev/null +++ b/forms/f1040/2026/pdf/forms/general/return-assembly/schedule3.ts @@ -0,0 +1,34 @@ +import { schedule3PdfFor } from "../../../../../2025/pdf/forms/general/return-assembly/schedule3.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Schedule 3 draft (page 1 is the IRS cover sheet). Line 5b is +// reserved and new line 13e (Form 1062) takes f1_34, so lines 14 and 15 move +// to f1_37 and f1_38. +const base = schedule3PdfFor(2026); +const draft = onTy2026Template( + base, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040s3--dft.pdf", + pdfSha256: + "5feb6f8d0f08191a2da153573d195fc354f385e26915b99b57d4588893b7cef0", + formPages: [1], + }, + { + "topmostSubform[0].Page1[0].f1_36[0]": "topmostSubform[0].Page1[0].f1_37[0]", + "topmostSubform[0].Page1[0].f1_37[0]": "topmostSubform[0].Page1[0].f1_38[0]", + }, + ["line5b_energy_efficient_home"], +); + +export const schedule3Pdf2026: PdfFormDescriptor = { + ...draft, + projectFields(fields, allPending) { + const projected = base.projectFields?.(fields, allPending) ?? fields; + const line5b = projected.line5b_energy_efficient_home; + if (typeof line5b === "number" && line5b > 0) { + throw new Error("TY2026 Schedule 3 line 5b is reserved"); + } + return projected; + }, +}; diff --git a/forms/f1040/2026/pdf/forms/taxes/other/schedule2.ts b/forms/f1040/2026/pdf/forms/taxes/other/schedule2.ts new file mode 100644 index 000000000..49301eef7 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/taxes/other/schedule2.ts @@ -0,0 +1,222 @@ +import { schedule2Pdf } from "../../../../../2025/pdf/forms/taxes/other/schedule2.ts"; +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { section1294DueFromCalculatedForm } from "../../../../../nodes/inputs/income/foreign/f8621/section1294.ts"; + +// IRS 2026 Schedule 2 draft (page 1 is the IRS cover sheet). Part I keeps the +// 2025 lines. Part II is regrouped: Section A income taxes (lines 4-15), +// Section B employment and other taxes (16a-20) and line 21. The 2025 values +// are reassigned to the 2026 lines below; the Form 8959 and Form 5329 amounts +// split by their source lines. Line 12 (section 965) is not in the total. +const P1 = "form1[0].Page1[0]"; +const P2 = "form1[0].Page2[0]"; + +const LINES: ReadonlyArray = [ + ["line1a_excess_advance_premium", `${P1}.Line1_ReadOrder[0].f1_03[0]`], + ["line1b_new_clean_vehicle_repayment", `${P1}.f1_04[0]`], + ["line1c_prev_owned_clean_vehicle_repayment", `${P1}.f1_05[0]`], + ["line1d_form4255_net_epe", `${P1}.f1_06[0]`], + ["line1e_form4255_excessive_payment", `${P1}.f1_07[0]`], + ["line1f_form4255_20_percent_ep", `${P1}.f1_08[0]`], + ["line1z_total_additions", `${P1}.f1_11[0]`], + ["line2_amt", `${P1}.f1_12[0]`], + ["line3_additional_tax", `${P1}.f1_13[0]`], + ["ty2026_line4", `${P1}.f1_15[0]`], + ["ty2026_line5", `${P1}.f1_16[0]`], + ["ty2026_line6", `${P1}.f1_17[0]`], + ["ty2026_line8", `${P1}.f1_19[0]`], + ["ty2026_line9", `${P1}.f1_20[0]`], + ["ty2026_line10", `${P1}.f1_21[0]`], + ["ty2026_line11", `${P1}.f1_22[0]`], + ["ty2026_line12", `${P1}.f1_23[0]`], + ["ty2026_line13a_description", `${P1}.Line13_ReadOrder[0].Line13a_ReadOrder[0].f1_24[0]`], + ["ty2026_line13a", `${P1}.Line13_ReadOrder[0].f1_25[0]`], + ["ty2026_line13b", `${P1}.f1_26[0]`], + ["ty2026_line13c", `${P1}.f1_27[0]`], + ["ty2026_line13d", `${P1}.f1_28[0]`], + ["ty2026_line13e", `${P1}.f1_29[0]`], + ["ty2026_line13f", `${P1}.f1_30[0]`], + ["ty2026_line13h", `${P1}.f1_32[0]`], + ["ty2026_line13k", `${P1}.f1_35[0]`], + ["ty2026_line14", `${P2}.f2_06[0]`], + ["ty2026_line15", `${P2}.f2_07[0]`], + ["ty2026_line16a", `${P2}.Line16_ReadOrder[0].f2_08[0]`], + ["ty2026_line16b", `${P2}.f2_09[0]`], + ["ty2026_line16c", `${P2}.f2_10[0]`], + ["ty2026_line17a", `${P2}.Line17_ReadOrder[0].f2_11[0]`], + ["ty2026_line17b", `${P2}.f2_12[0]`], + ["ty2026_line17c", `${P2}.f2_13[0]`], + ["ty2026_line17d", `${P2}.f2_14[0]`], + ["ty2026_line18", `${P2}.f2_15[0]`], + ["ty2026_line19a", `${P2}.Line19a_ReadOrder[0].f2_16[0]`], + ["ty2026_line19b", `${P2}.f2_17[0]`], + ["ty2026_line19c", `${P2}.f2_18[0]`], + ["ty2026_line20", `${P2}.f2_19[0]`], + ["ty2026_line21", `${P2}.f2_20[0]`], +]; + +const BOXES: ReadonlyArray = [ + ["line1e_form4255_row1d", `${P1}.Line1e_ReadOrder[0].c1_1[2]`], + ["line1e_form4255_row2a", `${P1}.Line1e_ReadOrder[0].c1_1[3]`], + ["line1f_form4255_row1d", `${P1}.Line1f_ReadOrder[0].c1_2[2]`], + ["line1f_form4255_row2a", `${P1}.Line1f_ReadOrder[0].c1_2[3]`], +]; + +const fields: ReadonlyArray = [ + ...LINES.map(([domainKey, pdfField]): PdfFieldEntry => ({ + kind: "text", + domainKey, + pdfField, + })), + ...BOXES.map(([domainKey, pdfField]): PdfFieldEntry => ({ + kind: "checkbox", + domainKey, + pdfField, + })), +]; + +const sum = (values: readonly number[]) => + values.reduce((total, value) => total + value, 0); + +function refuseUnplaced( + amount: (key: string) => number, + allPending: Record>, +): void { + const reduction = allPending.form8978_reporting_year + ?.schedule2_line17z_reduction; + if ( + (typeof reduction === "number" && reduction > 0) || + section1294DueFromCalculatedForm(allPending.form8621).tax > 0 || + amount("line17z_other_additional_taxes") > 0 + ) { + throw new Error( + "TY2026 PDF Schedule 2 line 13z is not mapped; export MeF XML", + ); + } +} + +function form5329Split( + amount: (key: string) => number, + fields: Record, +): { line5: number; line18: number } { + const total = amount("line8_form5329_tax"); + if (total > 0 && typeof fields.line8_form5329_chapter1_tax !== "number") { + throw new Error( + "TY2026 PDF Schedule 2 lines 5 and 18 need the Form 5329 chapter 1 tax", + ); + } + const line5 = amount("line8_form5329_chapter1_tax"); + return { line5, line18: total - line5 }; +} + +function form8959Split( + amount: (key: string) => number, + allPending: Record>, +): { line11: number; line17b: number } { + const total = amount("line11_additional_medicare"); + const se = allPending.form8959?.line13_se_tax; + if (total > 0 && typeof se !== "number") { + throw new Error( + "TY2026 PDF Schedule 2 lines 11 and 17b need Form 8959 line 13", + ); + } + const line11 = typeof se === "number" ? se : 0; + return { line11, line17b: total - line11 }; +} + +/** The 2025 Part II amounts on the 2026 lines. */ +function ty2026Lines( + projected: Record, + allPending: Record>, +): Record { + const amount = (key: string): number => + typeof projected[key] === "number" ? projected[key] as number : 0; + refuseUnplaced(amount, allPending); + const { line5, line18 } = form5329Split(amount, projected); + const { line11, line17b } = form8959Split(amount, allPending); + const line13 = [ + amount("line17a_investment_credit_recapture"), + amount("line17b_mortgage_subsidy_recapture"), + amount("line17c_hsa_penalty"), + amount("line17d_hsa_eligibility_tax"), + amount("line17e_archer_msa_tax"), + amount("line17f_medicare_advantage_msa_tax"), + amount("line17h_nqdc_total"), + amount("line17k_golden_parachute_total"), + ]; + const line14 = sum(line13); + const line15 = sum([ + amount("line4_se_tax"), + line5, + amount("line12_niit"), + amount("line15_section453a_interest"), + amount("line16_lihtc_recapture"), + amount("line19_form4255_net_epe"), + line11, + line14, + ]); + const line16c = amount("line5_unreported_tip_tax") + + amount("line6_uncollected_8919"); + const line17d = amount("line9_household_employment") + line17b + + amount("line13_uncollected_fica_total"); + const line19c = amount("line17p_form8621_interest") + + amount("line17q_form8621_1294_interest"); + const line20 = line16c + line17d + line18 + line19c; + const line21 = line15 + line20; + if (Math.abs(line21 - amount("line21_total")) > 0.5) { + throw new Error( + "TY2026 PDF Schedule 2 line 21 differs from the 2025-layout total", + ); + } + return { + ...projected, + ty2026_line4: amount("line4_se_tax"), + ty2026_line5: line5, + ty2026_line6: amount("line12_niit"), + ty2026_line8: amount("line15_section453a_interest"), + ty2026_line9: amount("line16_lihtc_recapture"), + ty2026_line10: amount("line19_form4255_net_epe"), + ty2026_line11: line11, + ty2026_line12: amount("line20_965_tax_installment"), + ty2026_line13a_description: projected.line17a_description, + ty2026_line13a: line13[0], + ty2026_line13b: line13[1], + ty2026_line13c: line13[2], + ty2026_line13d: line13[3], + ty2026_line13e: line13[4], + ty2026_line13f: line13[5], + ty2026_line13h: line13[6], + ty2026_line13k: line13[7], + ty2026_line14: line14, + ty2026_line15: line15, + ty2026_line16a: amount("line5_unreported_tip_tax"), + ty2026_line16b: amount("line6_uncollected_8919"), + ty2026_line16c: line16c, + ty2026_line17a: amount("line9_household_employment"), + ty2026_line17b: line17b, + ty2026_line17c: amount("line13_uncollected_fica_total"), + ty2026_line17d: line17d, + ty2026_line18: line18, + ty2026_line19a: amount("line17p_form8621_interest"), + ty2026_line19b: amount("line17q_form8621_1294_interest"), + ty2026_line19c: line19c, + ty2026_line20: line20, + ty2026_line21: line21, + }; +} + +export const schedule2Pdf2026: PdfFormDescriptor = { + pendingKey: schedule2Pdf.pendingKey, + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040s2--dft.pdf", + pdfSha256: "0e3d4faa4b96f692bc31119d2c86189c3d8b9263e8f3aaaf7c3da371887ebc56", + pageIndices: () => [1, 2], + filerFields: schedule2Pdf.filerFields, + fields, + projectFields(fields, allPending) { + const projected = schedule2Pdf.projectFields?.(fields, allPending) ?? + fields; + return ty2026Lines(projected, allPending); + }, +}; diff --git a/scripts/release/smoke-release-binary.ts b/scripts/release/smoke-release-binary.ts index 8c1b4d363..49c9276fc 100644 --- a/scripts/release/smoke-release-binary.ts +++ b/scripts/release/smoke-release-binary.ts @@ -339,6 +339,49 @@ try { throw new Error("Built asset PDF omitted a Form 1040 page"); } assertFlattenedPdfValues(pdf); + + const ty2026 = JSON.parse( + await invoke("return", "create", "--year", "2026"), + ).returnId as string; + for ( + const [nodeType, data] of [ + ["general", { ...general, taxpayer_signature_date: "2027-04-15" }], + ["w2", w2], + ] as const + ) { + await invoke( + "form", + "add", + "--returnId", + ty2026, + "--node_type", + nodeType, + JSON.stringify(data), + ); + } + const pdf2026Path = join(workingDir, "synthetic-return-2026.pdf"); + // TY2026 returns cannot be received until the 2027 processing year, so the + // timing rules (IND-002, IND-689-01) are overridden for this print check. + const notice = await invoke( + "return", + "export", + "--returnId", + ty2026, + "--type", + "pdf", + "--output", + pdf2026Path, + "--force", + ); + if (!notice.includes("IRS draft forms")) { + throw new Error("Built asset TY2026 PDF export did not name its draft forms"); + } + const pdf2026 = await PDFDocument.load(await sys.readBytes(pdf2026Path)); + if ( + pdf2026.getPageCount() !== 2 || pdf2026.getForm().getFields().length !== 0 + ) { + throw new Error("Built asset TY2026 PDF is not the flattened draft 1040"); + } if (transcriptPath) { const digest = new Uint8Array( await crypto.subtle.digest("SHA-256", pdfBytes), @@ -352,7 +395,7 @@ try { } console.log( - `Release smoke passed: ${expectedVersion}, synthetic W-2, finalized MeF, ${pdf.getPageCount()} PDF pages`, + `Release smoke passed: ${expectedVersion}, synthetic W-2, finalized MeF, ${pdf.getPageCount()} PDF pages, TY2026 draft PDF`, ); } finally { await sys.remove(workingDir, { recursive: true });