From b28d9cb032141aedbb07d3915bbb83bd02505992 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:24:55 +0000 Subject: [PATCH 01/26] fix(f1040): Form 8958 staged projection runs under the return's year Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../filing/f8958/staged_documents.test.ts | 46 +++++++++++++++---- .../general/filing/f8958/staged_documents.ts | 3 +- .../nodes/inputs/income/wages/w2/index.ts | 7 +-- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.test.ts b/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.test.ts index 5ffe6fcff..f9721f165 100644 --- a/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.test.ts +++ b/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.test.ts @@ -20,6 +20,14 @@ import { buildPending } from "../../../../../2025/mef/execution/pending.ts"; import { buildPdfBytes } from "../../../../../2025/pdf/builder.ts"; import { projectStagedForm8958Documents } from "./staged_documents.ts"; +type StagedStart = Parameters[0]; +type StagedFiler = Parameters[1]; +const project2025 = (start: StagedStart, filer: StagedFiler) => + projectStagedForm8958Documents(start, filer, { + taxYear: 2025, + formType: "f1040", + }); + const source = { domicile_state: CommunityPropertyState.CA, federal_filing_status: FilingStatus.MFS as const, @@ -118,7 +126,7 @@ const filer = { }; test("f8958 staged XML and PDF are derived from the reconciled ledger", () => { - const result = projectStagedForm8958Documents(start, filer); + const result = project2025(start, filer); assertEquals(result.execution.diagnostics, []); assertEquals(result.execution.pending.f1040?.line1a_wages, 50_000); assertEquals(result.execution.pending.f1040?.line25a_w2_withheld, 5_000); @@ -161,14 +169,14 @@ test("f8958 staged projection requires the reviewed spouse return to match both ] ) { assertThrows(() => - projectStagedForm8958Documents( + project2025( { ...start, f8958: { ...source, reviewed_spouse_return } }, filer, ) ); } assertThrows(() => - projectStagedForm8958Documents( + project2025( { ...start, f8958: { @@ -182,6 +190,26 @@ test("f8958 staged projection requires the reviewed spouse return to match both ); }); +test("f8958 staged projection runs the TY2026 return under its own year", () => { + const source2026 = { + ...source, + community_property_period: { + ...source.community_property_period, + from: "2026-01-01", + through: "2026-12-31", + }, + reviewed_spouse_return: { ...source.reviewed_spouse_return, tax_year: 2026 }, + }; + const result = projectStagedForm8958Documents( + { ...start, f8958: source2026 }, + filer, + { taxYear: 2026, formType: "f1040" }, + ); + assertEquals(result.execution.diagnostics, []); + assertEquals(result.execution.pending.f1040?.line1a_wages, 50_000); + assertThrows(() => project2025({ ...start, f8958: source2026 }, filer)); +}); + test("f8958 staged projection rejects altered W-2 and filer source", () => { for ( const changed of [ @@ -193,13 +221,13 @@ test("f8958 staged projection rejects altered W-2 and filer source", () => { }, ] ) { - assertThrows(() => projectStagedForm8958Documents(changed, filer)); + assertThrows(() => project2025(changed, filer)); } }); test("f8958 staged projection rejects non-wage rows and absent filer identity", () => { assertThrows(() => - projectStagedForm8958Documents( + project2025( { ...start, f8958: { @@ -215,7 +243,7 @@ test("f8958 staged projection rejects non-wage rows and absent filer identity", ) ); assertThrows(() => - projectStagedForm8958Documents( + project2025( start, { ...filer, firstName: undefined }, ) @@ -256,7 +284,7 @@ test("f8958 staged whole-dollar allocations conserve both shares and preserve th line25a_w2_withheld: spouseWithheld, line25d_total_withholding: spouseWithheld, }); - const result = projectStagedForm8958Documents(input, filer); + const result = project2025(input, filer); const pending = result.execution.pending; assertEquals(pending.f1040.line11_agi, taxpayerWage); assertEquals(pending.f1040.line25d_total_withholding, taxpayerWithheld); @@ -274,7 +302,7 @@ test("f8958 staged whole-dollar allocations conserve both shares and preserve th }); test("f8958 complete staged execution remains blocked by both public exporters", async () => { - const staged = projectStagedForm8958Documents(start, filer); + const staged = project2025(start, filer); // The staged projector deliberately accepts a minimal general source; // public export also needs the retained digital-assets answer. const executed = f1040_2025.executeReturn({ @@ -310,7 +338,7 @@ try { componentSchemaAvailable = sys.statSync(componentSchema).isFile; } catch { /* local IRS schema */ } test.skipIf(!componentSchemaAvailable)("f8958 staged wage and withholding component validates against TY2025 XSD", async () => { - const staged = projectStagedForm8958Documents(start, filer); + const staged = project2025(start, filer); // Supply only the component namespace/required document ID, not a fake return. const xml = staged.xml.replace( "", diff --git a/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.ts b/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.ts index cde2e45ff..50801fcf5 100644 --- a/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.ts +++ b/forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { execute } from "../../../../../../../core/runtime/executor.ts"; import { buildExecutionPlan } from "../../../../../../../core/runtime/planner.ts"; import { registry } from "../../../../../2025/registry.ts"; +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { type FilerIdentity, FilingStatus as MefFilingStatus, @@ -113,11 +114,11 @@ export const form8958StagedPdfFieldMap = { export function projectStagedForm8958Documents( rawStart: z.infer, filer: FilerIdentity, + ctx: NodeContext, ) { const start = stagedStartSchema.parse(rawStart); const source = start.f8958; const prepared = prepareF8958Allocation(source); - const ctx = { taxYear: 2025, formType: "f1040" }; const execution = execute(buildExecutionPlan(registry), registry, start, ctx); if (execution.diagnostics.length !== 0) { throw new Error("Form 8958 staged return has executor diagnostics"); diff --git a/forms/f1040/nodes/inputs/income/wages/w2/index.ts b/forms/f1040/nodes/inputs/income/wages/w2/index.ts index 02d0fd821..96088f85f 100644 --- a/forms/f1040/nodes/inputs/income/wages/w2/index.ts +++ b/forms/f1040/nodes/inputs/income/wages/w2/index.ts @@ -729,6 +729,7 @@ function wageFields(w2s: W2Items): F1040Input { function communityW2Fields( w2s: W2Items, allocation: NonNullable["f8958_allocation"]>, + taxYear: number, ): F1040Input { const item = w2s[0]; const wages = allocation.rows.find((row) => @@ -740,8 +741,8 @@ function communityW2Fields( if ( w2s.length !== 1 || !item || !wages || !withholding || allocation.rows.length !== 2 || - allocation.community_property_period.from !== "2025-01-01" || - allocation.community_property_period.through !== "2025-12-31" || + allocation.community_property_period.from !== `${taxYear}-01-01` || + allocation.community_property_period.through !== `${taxYear}-12-31` || wages.allocation_basis !== AllocationBasis.CommunityEqual || withholding.allocation_basis !== AllocationBasis.CommunityEqual || wages.source_document_id !== withholding.source_document_id || @@ -1341,7 +1342,7 @@ class W2Node extends TaxNode { const excessDeferral = codeDExcessDeferral(input.w2s, ctx.taxYear); const incomeTaxFields = input.f8958_allocation - ? communityW2Fields(input.w2s, input.f8958_allocation) + ? communityW2Fields(input.w2s, input.f8958_allocation, ctx.taxYear) : { ...withholdingFields(input.w2s), ...wageFields(input.w2s) }; const f1040Fields: F1040Input = { ...incomeTaxFields, From 4813af88ca818ad76a7fb8c7ca1b8a3ded88beeb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:24:55 +0000 Subject: [PATCH 02/26] fix(f1040): Schedule J source replay takes the return's context Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../form8995a/form8995a_mixed_fishing_source.ts | 7 ++++++- .../form8814_capital_schedulej_source.test.ts | 10 ++++++++-- .../schedule-j/schedule_j_fishing_source.test.ts | 16 +++++++++++----- .../schedule_j_joint_fishing_advanced.test.ts | 12 +++++++++--- ...edule_j_joint_fishing_positive_limits.test.ts | 12 +++++++++--- .../schedule_j_joint_fishing_source.test.ts | 12 +++++++++--- .../schedule_j_joint_fishing_zero_limit.test.ts | 12 +++++++++--- .../schedule_j_nonfarm_w2_source.test.ts | 10 ++++++++-- .../schedule_j_preferential_source.test.ts | 16 +++++++++++----- .../schedule-j/schedule_j_source_return.ts | 14 +++++++++----- .../schedule_j_staged_qef_source.test.ts | 2 +- .../form4972_schedule_j_child_amt_source.test.ts | 12 +++++++++--- .../return-processing/staged_source_return.ts | 2 +- 13 files changed, 100 insertions(+), 37 deletions(-) diff --git a/forms/f1040/2025/domains/deductions/business/form8995a/form8995a_mixed_fishing_source.ts b/forms/f1040/2025/domains/deductions/business/form8995a/form8995a_mixed_fishing_source.ts index 83f62dcd0..e2a901801 100644 --- a/forms/f1040/2025/domains/deductions/business/form8995a/form8995a_mixed_fishing_source.ts +++ b/forms/f1040/2025/domains/deductions/business/form8995a/form8995a_mixed_fishing_source.ts @@ -3,6 +3,7 @@ import type { Form8995AInput } from "../../../../../nodes/intermediate/forms/ded import { calculateMixedFishingQbi } from "../../../../../nodes/intermediate/forms/deductions/business/form8995a/mixed-fishing.ts"; import { assertScheduleJSourceReturn, + executeScheduleJSourceReturn, retainedFishingProfit, } from "../../../taxes/income-averaging/schedule-j/schedule_j_source_return.ts"; import { assertMultiBusinessInvestmentSources } from "../../../../mef/forms/deductions/business/f8995/f8995-investment.ts"; @@ -94,6 +95,10 @@ export function assertMixedFishingQbiReturn( "Mixed fishing QBI needs only the reviewed C/F and issued dividend income", ); } - assertScheduleJSourceReturn(pending); + assertScheduleJSourceReturn( + pending, + (inputs) => + executeScheduleJSourceReturn(inputs, { taxYear, formType: "f1040" }), + ); return calculated; } diff --git a/forms/f1040/2025/domains/income/investments/form8814/form8814_capital_schedulej_source.test.ts b/forms/f1040/2025/domains/income/investments/form8814/form8814_capital_schedulej_source.test.ts index 0359bfba0..7636715ce 100644 --- a/forms/f1040/2025/domains/income/investments/form8814/form8814_capital_schedulej_source.test.ts +++ b/forms/f1040/2025/domains/income/investments/form8814/form8814_capital_schedulej_source.test.ts @@ -6,9 +6,15 @@ import { f1040_2025 } from "../../../../index.ts"; import { normalizeAllPending } from "../../../../return-processing/pending.ts"; import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; -import { assertScheduleJSourceReturn } from "../../../taxes/income-averaging/schedule-j/schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "../../../taxes/income-averaging/schedule-j/schedule_j_source_return.ts"; import { form8814CapitalScheduleJInputs } from "./form8814_capital_schedulej.fixture.ts"; +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); + const cases = [ { id: "child-direct-line7a", @@ -86,7 +92,7 @@ for (const expected of cases) { { source: "schedule_d.net_capital_gain", amount: expected.capital }, ], ); - assertScheduleJSourceReturn(actual.pending); + assertScheduleJSourceReturn(actual.pending, replayScheduleJ); const noJ: any = structuredClone(input); delete noJ.schedule_j; const noElection = f1040_2025.executeReturn(noJ); diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_fishing_source.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_fishing_source.test.ts index 970ce2d07..a118fa7d1 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_fishing_source.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_fishing_source.test.ts @@ -14,11 +14,17 @@ import { scheduleJFishingInputs, scheduleJFishingPhaseInInputs, } from "./schedule_j_fishing_source.fixture.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; import { calculateMixedFishingQbi } from "../../../../../nodes/intermediate/forms/deductions/business/form8995a/mixed-fishing.ts"; import { form8995a } from "../../../../mef/forms/deductions/business/f8995a/f8995a.ts"; import { form8995aPdf } from "../../../../pdf/forms/deductions/business/f8995a.ts"; +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); + const evidenceDir = process.env.SCHEDULE_J_FISHING_EVIDENCE_DIR ?? ".state/research/schedulej-fishing-preferential-source"; const xsd = process.env.SCHEDULE_J_FISHING_XSD_PATH ?? @@ -67,7 +73,7 @@ test("retained commercial catch, one/two farms and dividends refigure actual Sch const noJ = await packet(`${kind}-without-j`, without); const elected = await packet(kind, input); const p = elected.pending; - assertScheduleJSourceReturn(elected.result.pending); + assertScheduleJSourceReturn(elected.result.pending, replayScheduleJ); assertEquals( p.schedule1.line3_schedule_c, kind === "fishing" ? 320_000 : 120_000, @@ -128,7 +134,7 @@ test("actual single-filer QBI phase-out at $247,300 leaves smaller mixed catch f assertEquals(p.f1040.line16_income_tax, 60_162); assertEquals(p.form6251.regular_tax, 62_451); assertEquals(noJ.pending.form6251.regular_tax, 62_451); - assertScheduleJSourceReturn(elected.result.pending); + assertScheduleJSourceReturn(elected.result.pending, replayScheduleJ); assertEquals(elected.xml.includes("30000"), true, ); - assertScheduleJSourceReturn(result.result.pending); + assertScheduleJSourceReturn(result.result.pending, replayScheduleJ); const filer = extractFilerIdentity(result.pending.f1040)!; for ( const mutate of [ @@ -219,7 +225,7 @@ test("fishing attribution rejects absent, mutated and mismatched source bytes", assertEquals(result.diagnostics, []); const pending: any = structuredClone(result.pending); pending.schedule_j_calculation.fishing_net_profit += 1; - assertThrows(() => assertScheduleJSourceReturn(pending), Error); + assertThrows(() => assertScheduleJSourceReturn(pending, replayScheduleJ), Error); const filer = extractFilerIdentity( normalizeAllPending(result.pending).f1040, )!; diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_advanced.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_advanced.test.ts index e02354b7a..88a68a9a5 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_advanced.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_advanced.test.ts @@ -8,9 +8,15 @@ import { scheduleJJointFishingAdvancedInputs } from "./schedule_j_joint_fishing. import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; import { normalizeAllPending } from "../../../../return-processing/pending.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; import { calculateMixedFishingQbi } from "../../../../../nodes/intermediate/forms/deductions/business/form8995a/mixed-fishing.ts"; +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); + const evidenceDir = process.env.SCHEDULE_J_JOINT_ADVANCED_EVIDENCE_DIR ?? ".state/research/schedulej-joint-advanced-oct6/packets"; const xsd = process.env.SCHEDULE_J_JOINT_XSD_PATH ?? @@ -60,7 +66,7 @@ test("joint C/F above MFJ QBI threshold files two owner-specific 8995-A phase-in const elected = await packet(`advanced-${fishingOwner}`, input); const p = elected.pending; const q = calculateMixedFishingQbi(p.form8995a, 2025); - assertScheduleJSourceReturn(elected.result.pending); + assertScheduleJSourceReturn(elected.result.pending, replayScheduleJ); assertEquals(q.profits, [200_000, 300_000]); assertEquals(q.allocations, [13_596, 14_935]); assertEquals(q.rows.map((row) => row.lines.line2), [186_404, 285_065]); @@ -124,7 +130,7 @@ test("advanced joint owner source and final filed 8995-A conflicts are rejected" ) { const pending: any = structuredClone(result.pending); change(pending); - assertThrows(() => assertScheduleJSourceReturn(pending)); + assertThrows(() => assertScheduleJSourceReturn(pending, replayScheduleJ)); await assertRejects(() => f1040_2025.prepareReturn(pending, filer)); await assertRejects(() => buildPdfBytes(pending, filer, ".pdf-cache")); } diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_positive_limits.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_positive_limits.test.ts index 267d169a7..1567e48ee 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_positive_limits.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_positive_limits.test.ts @@ -9,9 +9,15 @@ import { scheduleJJointFishingPositiveLimitsInputs } from "./schedule_j_joint_fi import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; import { normalizeAllPending } from "../../../../return-processing/pending.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; import { calculateMixedFishingQbi } from "../../../../../nodes/intermediate/forms/deductions/business/form8995a/mixed-fishing.ts"; +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); + const evidenceDir = process.env.SCHEDULE_J_JOINT_POSITIVE_EVIDENCE_DIR ?? ".state/research/schedulej-joint-positive-oct6/packets"; const xsd = process.env.SCHEDULE_J_JOINT_XSD_PATH ?? @@ -68,7 +74,7 @@ test("joint C/F wages and UBIA derive three distinct full phase-out limits", asy const elected = await packet(`${kind}-${owner}`, input); const p = elected.pending; const q = calculateMixedFishingQbi(p.form8995a, 2025); - assertScheduleJSourceReturn(elected.result.pending); + assertScheduleJSourceReturn(elected.result.pending, replayScheduleJ); assertEquals(q.profits[0], 200_000); assertEquals(q.profits[1], wages ? 478_185 : 500_000); assertEquals(q.rows.map((row) => row.lines.line15), [0, rowDed]); @@ -146,7 +152,7 @@ test("joint positive QBI source rejects payroll, W-2, ownership, property, and f const pending: any = structuredClone(result.pending); pending.form8995a.mixed_fishing_qbi_source.schedule_f .qbi_positive_limit_inventory.sha256 = "0".repeat(64); - assertThrows(() => assertScheduleJSourceReturn(pending)); + assertThrows(() => assertScheduleJSourceReturn(pending, replayScheduleJ)); await assertRejects(() => f1040_2025.prepareReturn(pending, filer)); await assertRejects(() => buildPdfBytes(pending, filer, ".pdf-cache")); }); diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_source.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_source.test.ts index ce0ae0da3..0f89fdfdc 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_source.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_source.test.ts @@ -8,7 +8,13 @@ import { scheduleJJointFishingInputs } from "./schedule_j_joint_fishing.fixture. import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; import { normalizeAllPending } from "../../../../return-processing/pending.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; + +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); const evidenceDir = process.env.SCHEDULE_J_JOINT_EVIDENCE_DIR ?? ".state/research/schedulej-joint-fishing-oct6/packets"; @@ -58,7 +64,7 @@ test("joint primary/spouse farm and fishing source elects owner-specific Schedul const noJ = await packet(`joint-${fishingOwner}-without-j`, noJInput); const elected = await packet(`joint-${fishingOwner}`, input); const p = elected.pending; - assertScheduleJSourceReturn(elected.result.pending); + assertScheduleJSourceReturn(elected.result.pending, replayScheduleJ); assertEquals(p.f1040.filing_status, "mfj"); assertEquals(p.schedule1.line3_schedule_c, 120_000); assertEquals(p.schedule1.line6_schedule_f, 200_000); @@ -125,7 +131,7 @@ test("joint Schedule J source and owner conflicts cannot be exported", async () ) { const pending: any = structuredClone(result.pending); change(pending); - assertThrows(() => assertScheduleJSourceReturn(pending)); + assertThrows(() => assertScheduleJSourceReturn(pending, replayScheduleJ)); await assertRejects(() => f1040_2025.prepareReturn(pending, filer)); await assertRejects(() => buildPdfBytes(pending, filer, ".pdf-cache")); } diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_zero_limit.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_zero_limit.test.ts index a9b70b2be..a21153664 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_zero_limit.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_joint_fishing_zero_limit.test.ts @@ -9,9 +9,15 @@ import { scheduleJJointFishingZeroLimitInputs } from "./schedule_j_joint_fishing import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; import { normalizeAllPending } from "../../../../return-processing/pending.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; import { calculateMixedFishingQbi } from "../../../../../nodes/intermediate/forms/deductions/business/form8995a/mixed-fishing.ts"; +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); + const evidenceDir = process.env.SCHEDULE_J_JOINT_ZERO_EVIDENCE_DIR ?? ".state/research/schedulej-joint-zero-oct6/packets"; const xsd = process.env.SCHEDULE_J_JOINT_XSD_PATH ?? @@ -61,7 +67,7 @@ test("joint C/F above full MFJ QBI phase-out retains two zero-limited 8995-A row const elected = await packet(`zero-${fishingOwner}`, input); const p = elected.pending; const q = calculateMixedFishingQbi(p.form8995a, 2025); - assertScheduleJSourceReturn(elected.result.pending); + assertScheduleJSourceReturn(elected.result.pending, replayScheduleJ); assertEquals(q.profits, [200_000, 400_000]); assertEquals(q.allocations, [13_596, 16_275]); assertEquals(q.rows.map((row) => row.lines.line15), [0, 0]); @@ -141,7 +147,7 @@ test("full-phase-out QBI requires byte-bound complete owner-specific zero-limit const pending: any = structuredClone(result.pending); pending.form8995a.mixed_fishing_qbi_source.schedule_f.qbi_zero_limit_inventory .sha256 = "0".repeat(64); - assertThrows(() => assertScheduleJSourceReturn(pending)); + assertThrows(() => assertScheduleJSourceReturn(pending, replayScheduleJ)); await assertRejects(() => f1040_2025.prepareReturn(pending, filer)); await assertRejects(() => buildPdfBytes(pending, filer, ".pdf-cache")); }); diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_nonfarm_w2_source.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_nonfarm_w2_source.test.ts index a4c9cceca..d1c05579c 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_nonfarm_w2_source.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_nonfarm_w2_source.test.ts @@ -9,9 +9,15 @@ import { normalizeAllPending } from "../../../../return-processing/pending.ts"; import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; import { scheduleJNonfarmW2Inputs } from "./schedule_j_nonfarm_w2.fixture.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; import { assertSingleFarmQbiReturn } from "../../../deductions/business/form8995a/form8995a_single_farm_reconciliation.ts"; +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); + const outputDir = process.env.SCHEDULE_J_NONFARM_W2_EVIDENCE_DIR ?? ".state/research/schedulej-nonfarm-w2-source"; @@ -68,7 +74,7 @@ test("issued nonfarm W-2 joins high-income farm Schedule J, SE, zero-limited QBI assertEquals(pending.form6251.line11_amt, 53_583); assertEquals(pending.f1040.line24_total_tax, 139_786); assertEquals(pending.f1040.line37_amount_owed, 119_786); - assertScheduleJSourceReturn(actual.pending); + assertScheduleJSourceReturn(actual.pending, replayScheduleJ); assertSingleFarmQbiReturn(pending, 2025); const filer = extractFilerIdentity(pending.f1040)!; const prepared = await f1040_2025.prepareReturn(actual.pending, filer); diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts index 3b229e5c5..72ca6059d 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts @@ -12,8 +12,14 @@ import { } from "../../retirement/form4972/form4972_preferential_amt.fixture.ts"; import { normalizeAllPending } from "../../../../return-processing/pending.ts"; import { extractFilerIdentity } from "../../../../../mef/filer.ts"; -import { assertScheduleJSourceReturn } from "./schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "./schedule_j_source_return.ts"; import { assertForm4972AmtJoin } from "../../retirement/form4972/form4972_amt_reconciliation.ts"; + +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); function sources(): Record { const farm: any = structuredClone( pdfReviewFixtures.find((f) => @@ -45,7 +51,7 @@ test("Schedule J derives preferential tax from issued dividend/farm sources and assertEquals(p.f1040.line16_income_tax, 30187); assertEquals(p.form6251.regular_tax, 31214); assertEquals(p.form6251.line11_amt, 55517); - assertScheduleJSourceReturn(actual.pending); + assertScheduleJSourceReturn(actual.pending, replayScheduleJ); assertForm4972AmtJoin(830, p); const filer = extractFilerIdentity(p.f1040)!; const prepared = await f1040_2025.prepareReturn!(actual.pending, filer); @@ -92,15 +98,15 @@ test("Schedule J derives preferential tax from issued dividend/farm sources and ) { const bad: any = structuredClone(actual.pending); bad[form][field] += 1; - assertThrows(() => assertScheduleJSourceReturn(bad)); + assertThrows(() => assertScheduleJSourceReturn(bad, replayScheduleJ)); await assertRejects(() => f1040_2025.prepareReturn!(bad, filer)); } const missing: any = structuredClone(actual.pending); delete missing.schedule_j_source_replay; - assertThrows(() => assertScheduleJSourceReturn(missing)); + assertThrows(() => assertScheduleJSourceReturn(missing, replayScheduleJ)); delete missing.schedule_j_calculation; delete missing.income_tax_calculation; - assertThrows(() => assertScheduleJSourceReturn(missing)); + assertThrows(() => assertScheduleJSourceReturn(missing, replayScheduleJ)); }); test("Schedule J rejects public private tax operands and stale current tax treatment", () => { const privateInput: any = sources(); diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_source_return.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_source_return.ts index 8151952f5..7cc890a82 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_source_return.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_source_return.ts @@ -19,6 +19,7 @@ import { type Form8814Lines, } from "../../../../../nodes/inputs/income/investments/f8814/index.ts"; import { registry } from "../../../../registry.ts"; +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { buildPending } from "../../../../mef/execution/pending.ts"; export const nonfarmEmployerRecordSchema = z.object({ @@ -234,7 +235,6 @@ function nonfarmWageAmount( return amount; } -const context = { taxYear: 2025, formType: "f1040" } as const; function record(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error( @@ -253,8 +253,9 @@ function total(value: unknown): number { } return result; } -function raw(inputs: Record): ExecuteResult { - return execute(buildExecutionPlan(registry), registry, inputs, context); +function graphFor(context: NodeContext) { + return (inputs: Record): ExecuteResult => + execute(buildExecutionPlan(registry), registry, inputs, context); } function jointFishingFarmOwners( @@ -373,7 +374,10 @@ function jointFishingFarmOwners( /** Innermost graph stage, reusable in credit and QEF counterfactual passes. */ export function executeScheduleJSourceReturn( inputs: Record, - executeGraph: (inputs: Record) => ExecuteResult = raw, + context: NodeContext, + executeGraph: (inputs: Record) => ExecuteResult = graphFor( + context, + ), ): ExecuteResult { if (inputs.schedule_j === undefined) return executeGraph(inputs); // Private worksheet operands cannot be supplied by a public caller. @@ -548,7 +552,7 @@ export function assertScheduleJSourceReturn( pending: Readonly>, executeFiledReturn: ( inputs: Record, - ) => ExecuteResult = executeScheduleJSourceReturn, + ) => ExecuteResult, ): void { const calculation = pending.schedule_j_calculation as | Record diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_staged_qef_source.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_staged_qef_source.test.ts index f064a237e..b2e17d650 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_staged_qef_source.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_staged_qef_source.test.ts @@ -255,7 +255,7 @@ test("Alex's Schedule J and adoption source retains staged credit without QEF", test("Alex's issued education sources compose the J and QEF shadow at the actual phaseout", async () => { const source = alexSources(true); delete source.form8839; - const pre = buildPending(executeScheduleJSourceReturn(source).pending); + const pre = buildPending(executeScheduleJSourceReturn(source, { taxYear: 2025, formType: "f1040" }).pending); const education = pdfReviewFixtures.find((row) => row.id === "single-form8863-lifetime-learning-scholarship" )!; diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts index 3594f3bb3..07044413a 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts @@ -10,7 +10,13 @@ import { normalizeAllPending } from "../../../../return-processing/pending.ts"; import { extractFilerIdentity } from "../../../../../mef/filer.ts"; import { buildPdfBytes, type PdfPageOrigin } from "../../../../pdf/builder.ts"; import { assertForm4972AmtJoin } from "./form4972_amt_reconciliation.ts"; -import { assertScheduleJSourceReturn } from "../../income-averaging/schedule-j/schedule_j_source_return.ts"; +import { + assertScheduleJSourceReturn, + executeScheduleJSourceReturn, +} from "../../income-averaging/schedule-j/schedule_j_source_return.ts"; + +const replayScheduleJ = (inputs: Record) => + executeScheduleJSourceReturn(inputs, { taxYear: 2025, formType: "f1040" }); function sourcedReturn(): Record { const input: any = scheduleJNonfarmW2Inputs(); @@ -68,7 +74,7 @@ test("issued participant, child, farm, dividend, W-2 and ISO sources refigure Sc const noJ: any = normalizeAllPending(without.pending); assertEquals(noJ.f1040.line16_income_tax - 2768, 72381); assertEquals(noJ.f1040.form8814_tax, 135); - assertScheduleJSourceReturn(result.pending); + assertScheduleJSourceReturn(result.pending, replayScheduleJ); assertForm4972AmtJoin(2768, p); const filer = extractFilerIdentity(p.f1040)!; @@ -148,7 +154,7 @@ test("child income just above filing threshold retains a zero-tax Form8814 with assertEquals(p.f1040.line16_income_tax, 71257); assertEquals(p.form6251.regular_tax, 71996); assertEquals(p.form6251.line11_amt, 53583); - assertScheduleJSourceReturn(r.pending); + assertScheduleJSourceReturn(r.pending, replayScheduleJ); assertForm4972AmtJoin(2768, p); const filer = extractFilerIdentity(p.f1040)!; const prepared = await f1040_2025.prepareReturn!(r.pending, filer); diff --git a/forms/f1040/2025/return-processing/staged_source_return.ts b/forms/f1040/2025/return-processing/staged_source_return.ts index 8e45d5a57..1807141f4 100644 --- a/forms/f1040/2025/return-processing/staged_source_return.ts +++ b/forms/f1040/2025/return-processing/staged_source_return.ts @@ -35,7 +35,7 @@ function executeCreditStages( executeGraph: F1040GraphExecutor, ): ExecuteResult { const scheduleJGraph = (source: Record) => - executeScheduleJSourceReturn(source, executeGraph); + executeScheduleJSourceReturn(source, context, executeGraph); const elected = Array.isArray(inputs.f8621) && inputs.f8621.some((item) => item !== null && typeof item === "object" && From 028ba3c66f13d09a7cc589de44219368208061c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:24:55 +0000 Subject: [PATCH 03/26] fix(f1040): Form 8990 two-pass and its MeF/PDF reconcile take the return's context Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../business/form8990/form8990_projection.ts | 3 +++ .../income/business/form172/form172_form8990_return.ts | 3 +++ .../income/business/form172/form172_modified_income.ts | 2 +- .../business/form172/form172_projected_return.test.ts | 2 +- .../business/form172/form172_projected_return.ts | 6 +++--- .../f1040/2025/mef/forms/deductions/business/f8990.ts | 9 +++++++-- .../f1040/2025/pdf/forms/deductions/business/f8990.ts | 10 +++++++++- .../deductions/business/form8990/run-two-pass.test.ts | 1 + .../forms/deductions/business/form8990/run-two-pass.ts | 2 +- .../business/form8990/schedule-c-pass.test.ts | 6 +++--- .../deductions/business/form8990/schedule-c-pass.ts | 6 ++---- 11 files changed, 34 insertions(+), 16 deletions(-) diff --git a/forms/f1040/2025/domains/deductions/business/form8990/form8990_projection.ts b/forms/f1040/2025/domains/deductions/business/form8990/form8990_projection.ts index 5345133d0..9367c8f12 100644 --- a/forms/f1040/2025/domains/deductions/business/form8990/form8990_projection.ts +++ b/forms/f1040/2025/domains/deductions/business/form8990/form8990_projection.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { form8990LinesSchema, publicInputSchema, @@ -81,10 +82,12 @@ export function projectForm8990ForExport( export function reconcileForm8990Projection( raw: unknown, allPending: Readonly>, + context: NodeContext, ): Form8990Projection { const projected = projectionSchema.parse(raw); const source = projected.sourceRecords; const result = runBoundedForm8990TwoPass({ + context, returnInputs: source.returnInputs, receipts: source.form8990.receipts, interestExpenseRecords: source.form8990.interestExpenseRecords, diff --git a/forms/f1040/2025/domains/income/business/form172/form172_form8990_return.ts b/forms/f1040/2025/domains/income/business/form172/form172_form8990_return.ts index 1e1caa95c..5199182ec 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_form8990_return.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_form8990_return.ts @@ -1,11 +1,13 @@ import { publicInputSchema } from "../../../../../nodes/intermediate/forms/deductions/business/form8990/index.ts"; import { runBoundedForm8990TwoPass } from "../../../../../nodes/intermediate/forms/deductions/business/form8990/run-two-pass.ts"; import { execute, type ExecuteResult } from "../../../../../../../core/runtime/executor.ts"; +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; /** Internal composed workpaper. Public8990 still requires authentic source and * accepted carry persistence; this does not create an admitted export route. */ export function executeForm172Form8990Return( inputs: Record, + context: NodeContext, executeGraph?: ( inputs: Record, context: Parameters[3], @@ -17,6 +19,7 @@ export function executeForm172Form8990Return( Object.entries(inputs).filter(([key]) => key !== "form8990"), ); const twoPass = runBoundedForm8990TwoPass({ + context, returnInputs, ...source, executeGraph, diff --git a/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts b/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts index 80bbe617b..f1e91ccb2 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts @@ -79,7 +79,7 @@ export function reconcileForm172ModifiedIncome( true, graph, ) - : executeForm172Form8990Return(structuredClone(inputs), graph).execution; + : executeForm172Form8990Return(structuredClone(inputs), modifiedContext, graph).execution; if (execution.diagnostics.length) { throw new Error("Modified NOL income needs a successful source graph"); } diff --git a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts index a40c3ee6f..43029ce28 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts @@ -846,7 +846,7 @@ const interestSourceRecords = { async function interestFixture() { const f = await fixture(); f.inputs = { ...interestBaseInputs, form8990: interestSourceRecords }; - const before = executeForm172Form8990Return(f.inputs); + const before = executeForm172Form8990Return(f.inputs, { taxYear: 2025, formType: "f1040" }); const return1040 = before.execution.pending.f1040!; const a = f.currentReview.annual_review; a.agi = Number(return1040.line11_agi); diff --git a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts index aefe19c26..63a836501 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts @@ -22,12 +22,13 @@ export async function stageForm172ProjectedReturn( rawDocuments: readonly SourceDocumentBytes[], ) { const inputs = structuredClone(rawInputs); + const nolContext = { taxYear: 2025, formType: "f1040" } as const; const staged = await stageForm172ReviewedReturnCalculation( inputs, rawBinding, rawDocuments, inputs.form8990 !== undefined - ? (source) => executeForm172Form8990Return(source).execution + ? (source) => executeForm172Form8990Return(source, nolContext).execution : undefined, ); // These routes need their source-dependent staging composed with the NOL @@ -76,7 +77,6 @@ export async function stageForm172ProjectedReturn( } } const projectedRegistry = { ...registry, start: new RetainedNolStart() }; - const nolContext = { taxYear: 2025, formType: "f1040" } as const; const executeNolGraph = ( source: Record, context: Parameters[3] = nolContext, @@ -90,7 +90,7 @@ export async function stageForm172ProjectedReturn( // Recompute return-derived adoption/education operands on every NOL pass. const interestComposition = inputs.form8990 === undefined ? undefined - : executeForm172Form8990Return(inputs, executeNolGraph, deduction); + : executeForm172Form8990Return(inputs, nolContext, executeNolGraph, deduction); const execution = interestComposition?.execution ?? executePreQefSourceReturn(inputs, nolContext, true, executeNolGraph); if (execution.diagnostics.length > 0) { diff --git a/forms/f1040/2025/mef/forms/deductions/business/f8990.ts b/forms/f1040/2025/mef/forms/deductions/business/f8990.ts index f4fa11049..9865030f5 100644 --- a/forms/f1040/2025/mef/forms/deductions/business/f8990.ts +++ b/forms/f1040/2025/mef/forms/deductions/business/f8990.ts @@ -2,6 +2,7 @@ import type { Form8990Lines } from "../../../../../nodes/intermediate/forms/dedu import { reconcileForm8990Projection } from "../../../../domains/deductions/business/form8990/form8990_projection.ts"; import { element, elements } from "../../../../../mef/xml.ts"; import type { MefFormDescriptor } from "../../../form-descriptor.ts"; +import { mefTaxYear } from "../../../support/year-config.ts"; type Input = Partial & Record; @@ -34,9 +35,13 @@ export const FIELD_MAP: ReadonlyArray = function buildIRS8990( fields: Input, pending: Readonly>, + taxYear: number, ): string { if (Object.keys(fields).length === 0) return ""; - const projected = reconcileForm8990Projection(fields, pending); + const projected = reconcileForm8990Projection(fields, pending, { + taxYear, + formType: "f1040", + }); return elements( "IRS8990", FIELD_MAP.map(([key, tag]) => element(tag, projected[key])), @@ -52,6 +57,6 @@ export const form8990: MefFormDescriptor<"form8990", Input> = { if (!context?.pending) { throw new Error("Form 8990 MeF needs the finalized return graph"); } - return buildIRS8990(fields, context.pending); + return buildIRS8990(fields, context.pending, mefTaxYear(context)); }, }; diff --git a/forms/f1040/2025/pdf/forms/deductions/business/f8990.ts b/forms/f1040/2025/pdf/forms/deductions/business/f8990.ts index 394878d75..27c324a01 100644 --- a/forms/f1040/2025/pdf/forms/deductions/business/f8990.ts +++ b/forms/f1040/2025/pdf/forms/deductions/business/f8990.ts @@ -1,6 +1,9 @@ import type { PdfFieldEntry, PdfFormDescriptor } from "../../../review-support/form-descriptor.ts"; import { reconcileForm8990Projection } from "../../../../domains/deductions/business/form8990/form8990_projection.ts"; import { buildPending } from "../../../../mef/execution/pending.ts"; +import { mefTaxYear } from "../../../../mef/support/year-config.ts"; + +const pdfContext = () => ({ taxYear: mefTaxYear(), formType: "f1040" }); // Canonical AcroForm /Fields and page /Widget positions of the Rev. Dec. 2025 // PDF, Part I only. Page 1 f1_1..5 are identity/foreign-entity header fields, @@ -62,6 +65,7 @@ export const form8990Pdf: PdfFormDescriptor = { const projected = reconcileForm8990Projection( fields, buildPending(allPending), + pdfContext(), ); return { ...projected, @@ -73,7 +77,11 @@ export const form8990Pdf: PdfFormDescriptor = { }, includeWhen(fields, allPending) { if (Object.keys(fields).length === 0) return false; - reconcileForm8990Projection(fields, buildPending(allPending ?? {})); + reconcileForm8990Projection( + fields, + buildPending(allPending ?? {}), + pdfContext(), + ); return true; }, fields, diff --git a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.test.ts b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.test.ts index 50b61b8bb..37433ff53 100644 --- a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.test.ts +++ b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.test.ts @@ -8,6 +8,7 @@ import { runBoundedForm8990TwoPass } from "./run-two-pass.ts"; test("2025 Form 8990 bounded two-pass recomputes Schedule C after a sourced disallowance", () => { const result = runBoundedForm8990TwoPass({ + context: { taxYear: 2025, formType: "f1040" }, returnInputs: { general: { filing_status: "single", taxpayer_ssn: "123456789" }, schedule_c: [{ diff --git a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.ts b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.ts index 664a40b64..1fdeebaf6 100644 --- a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.ts +++ b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/run-two-pass.ts @@ -69,7 +69,7 @@ export interface BoundedForm8990TwoPassResult { */ export function runBoundedForm8990TwoPass(args: { /** Tax year and form; the passes add their Schedule C permit to it. */ - readonly context?: NodeContext; + readonly context: NodeContext; readonly returnInputs: Readonly>; /** Internal source-composed executor; preserve the exact per-pass context. */ readonly executeGraph?: ( diff --git a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.test.ts b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.test.ts index d51c2daa7..4312c471a 100644 --- a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.test.ts +++ b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.test.ts @@ -42,13 +42,13 @@ test("2025 Form 8990 internal contexts permit exact provisional and finalized Sc schedule_cs: [business], }); const first = scheduleC.compute( - provisionalScheduleCContext(provisional), + provisionalScheduleCContext(provisional, { taxYear: 2025, formType: "f1040" }), provisional.source, ); assertEquals(schedule1Profit(first), 183_500); const finalized = applyCalculatedInterestAllowance(provisional, 3_000); const second = scheduleC.compute( - finalizedScheduleCContext(finalized), + finalizedScheduleCContext(finalized, { taxYear: 2025, formType: "f1040" }), finalized.source, ); assertEquals(schedule1Profit(second), 188_500); @@ -69,7 +69,7 @@ test("2025 Form 8990 internal context cannot approve a different Schedule C", () }); assertThrows( () => - scheduleC.compute(provisionalScheduleCContext(provisional), { + scheduleC.compute(provisionalScheduleCContext(provisional, { taxYear: 2025, formType: "f1040" }), { schedule_cs: [{ ...business, line_1_gross_receipts: 250_000 }], }), Error, diff --git a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.ts b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.ts index e1388ef5d..b5d421f35 100644 --- a/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.ts +++ b/forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.ts @@ -7,8 +7,6 @@ type Form8990ScheduleCPass = const passes = new WeakMap(); -const sourceReturnContext = { taxYear: 2025, formType: "f1040" } as const; - function contextFor( pass: Form8990ScheduleCPass, base: NodeContext, @@ -21,7 +19,7 @@ function contextFor( /** Internal first-pass permit; never represented in public tax input JSON. */ export function provisionalScheduleCContext( pass: ProvisionalScheduleCInterestPass, - base: NodeContext = sourceReturnContext, + base: NodeContext, ): NodeContext { return contextFor({ phase: "provisional", source: pass.source }, base); } @@ -29,7 +27,7 @@ export function provisionalScheduleCContext( /** Internal final-pass permit, after a calculated allowance has been applied. */ export function finalizedScheduleCContext( pass: FinalizedScheduleCInterestPass, - base: NodeContext = sourceReturnContext, + base: NodeContext, ): NodeContext { return contextFor({ phase: "finalized", source: pass.source }, base); } From b05ba91bcda0d4d0f7c7016fdd9c2c1d6b0389cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:24:56 +0000 Subject: [PATCH 04/26] fix(f1040): Form 7203 prior reduced note execution takes the return's context Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../form7203/form7203_prior_reduced_execution.ts | 5 ++++- .../income/business/form7203/debt-note.test.ts | 14 ++++++++++++-- .../income/business/form7203/prior-reduced-note.ts | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/forms/f1040/2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts b/forms/f1040/2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts index 48cf3521c..e7943ba28 100644 --- a/forms/f1040/2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts +++ b/forms/f1040/2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts @@ -6,6 +6,7 @@ import type { SourceDocumentBytes, SourceDocumentClaim, } from "../../../../../../../core/runtime/source-documents.ts"; +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { buildExecutionPlan } from "../../../../../../../core/runtime/planner.ts"; import { registry } from "../../../../registry.ts"; import { inputSchema as k1InputSchema } from "../../../../../nodes/inputs/income/rental-passthrough/k1_s_corp/index.ts"; @@ -20,6 +21,7 @@ import { inspectPrior7203MeFXml } from "../../../../../nodes/intermediate/forms/ export async function executePriorReduced7203WithSourceDocuments( inputs: Record, documents: readonly SourceDocumentBytes[], + context: NodeContext, ): Promise< DocumentBoundExecuteResult & { readonly stagedPriorReducedNoteGain: ReturnType< @@ -41,6 +43,7 @@ export async function executePriorReduced7203WithSourceDocuments( const stagedPriorReducedNoteGain = calculatePriorReducedNoteGainCandidate( source, k1, + context, ); const stock = k1.form7203_stock_loss_ledger; const general = inputs.general as Record | undefined; @@ -104,7 +107,7 @@ export async function executePriorReduced7203WithSourceDocuments( buildExecutionPlan(registry), registry, inputs, - { taxYear: 2025, formType: "f1040" }, + context, claims, documents, ); diff --git a/forms/f1040/nodes/intermediate/forms/income/business/form7203/debt-note.test.ts b/forms/f1040/nodes/intermediate/forms/income/business/form7203/debt-note.test.ts index 96c23ffb0..a2e1701b0 100644 --- a/forms/f1040/nodes/intermediate/forms/income/business/form7203/debt-note.test.ts +++ b/forms/f1040/nodes/intermediate/forms/income/business/form7203/debt-note.test.ts @@ -5,7 +5,7 @@ import { shiftDatedFacts } from "../../../../../../../../scripts/testing/sample- import { k1SCorpNode } from "../../../../../inputs/income/rental-passthrough/k1_s_corp/index.ts"; import { reconcileNewFormalNotes } from "./debt-note.ts"; import { - calculatePriorReducedNoteGainCandidate, + calculatePriorReducedNoteGainCandidate as gainCandidate, calculatePriorReducedNoteWorkpaper, } from "./prior-reduced-note.ts"; import { buildReviewedStockLoss7203 } from "../../../../../../2025/mef/forms/income/business/f7203_stock_loss.ts"; @@ -14,7 +14,17 @@ import { buildReviewedStockLossScheduleE } from "../../../../../../2025/mef/form import { scheduleEStockLossPdf } from "../../../../../../2025/pdf/forms/income/rental-passthrough/schedule_e_stock_loss.ts"; import { form7203 as form7203Node } from "./index.ts"; import { FilingStatus } from "../../../../../../mef/header.ts"; -import { executePriorReduced7203WithSourceDocuments } from "../../../../../../2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts"; +import { executePriorReduced7203WithSourceDocuments as executePriorReduced } from "../../../../../../2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts"; + +const ctx2025 = { taxYear: 2025, formType: "f1040" }; +const calculatePriorReducedNoteGainCandidate = ( + raw: unknown, + k1: Parameters[1], +) => gainCandidate(raw, k1, ctx2025); +const executePriorReduced7203WithSourceDocuments = ( + inputs: Parameters[0], + documents: Parameters[1], +) => executePriorReduced(inputs, documents, ctx2025); export const oneNote = { kind: "new_current_year_formal_notes", diff --git a/forms/f1040/nodes/intermediate/forms/income/business/form7203/prior-reduced-note.ts b/forms/f1040/nodes/intermediate/forms/income/business/form7203/prior-reduced-note.ts index dbe9a076f..1868911e6 100644 --- a/forms/f1040/nodes/intermediate/forms/income/business/form7203/prior-reduced-note.ts +++ b/forms/f1040/nodes/intermediate/forms/income/business/form7203/prior-reduced-note.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { NodeContext } from "../../../../../../../../core/types/node-context.ts"; import { assertForm8949TransactionMath, form8949, @@ -198,6 +199,7 @@ export function calculatePriorReducedNoteWorkpaper( export function calculatePriorReducedNoteGainCandidate( raw: unknown, k1: Parameters[1], + context: NodeContext, ) { const workpaper = calculatePriorReducedNoteWorkpaper(raw, k1); const source = workpaper.source; @@ -223,7 +225,6 @@ export function calculatePriorReducedNoteGainCandidate( is_long_term: true, }); assertForm8949TransactionMath(transaction); - const context = { taxYear: 2025, formType: "f1040" }; const form8949Result = form8949.compute(context, { transaction }); const routed = form8949Result.outputs.filter((output) => output.nodeType === "schedule_d" From 1dba827cf897b961ef17dfb97a4ed31ee3686955 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:25:10 +0000 Subject: [PATCH 05/26] chore: shrink year-pinned baseline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- scripts/testing/year-pinned-baseline.json | 30 ----------------------- 1 file changed, 30 deletions(-) diff --git a/scripts/testing/year-pinned-baseline.json b/scripts/testing/year-pinned-baseline.json index f41756a46..887066bed 100644 --- a/scripts/testing/year-pinned-baseline.json +++ b/scripts/testing/year-pinned-baseline.json @@ -394,11 +394,6 @@ "pattern": "year-value", "count": 1 }, - { - "path": "forms/f1040/2025/domains/income/business/form7203/form7203_prior_reduced_execution.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/2025/domains/income/business/passive-s-corp-loss-copies.ts", "pattern": "year-value", @@ -494,11 +489,6 @@ "pattern": "date-pinned", "count": 2 }, - { - "path": "forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_source_return.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_source_return.ts", "pattern": "z-literal", @@ -1369,11 +1359,6 @@ "pattern": "year-value", "count": 2 }, - { - "path": "forms/f1040/nodes/inputs/general/filing/f8958/staged_documents.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/nodes/inputs/general/filing/general/index.ts", "pattern": "year-identifier", @@ -1614,11 +1599,6 @@ "pattern": "year-identifier", "count": 1 }, - { - "path": "forms/f1040/nodes/inputs/income/wages/w2/index.ts", - "pattern": "date-pinned", - "count": 2 - }, { "path": "forms/f1040/nodes/inputs/income/wages/w2/index.ts", "pattern": "z-literal", @@ -1984,11 +1964,6 @@ "pattern": "z-literal", "count": 1 }, - { - "path": "forms/f1040/nodes/intermediate/forms/deductions/business/form8990/schedule-c-pass.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/nodes/intermediate/forms/deductions/investments/form4952/debt_trace.ts", "pattern": "date-pinned", @@ -2114,11 +2089,6 @@ "pattern": "year-identifier", "count": 3 }, - { - "path": "forms/f1040/nodes/intermediate/forms/income/business/form7203/prior-reduced-note.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/nodes/intermediate/forms/income/business/form8582/index.ts", "pattern": "z-literal", From 320a2932cc2c93f1bc3abb702ab9c69edbacdc34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:27:18 +0000 Subject: [PATCH 06/26] fix(cli): Form 8990 workpaper reconciles under the return's year Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- cli/store/form8990-workpaper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/store/form8990-workpaper.ts b/cli/store/form8990-workpaper.ts index d7ba013f6..9e1b1c0fb 100644 --- a/cli/store/form8990-workpaper.ts +++ b/cli/store/form8990-workpaper.ts @@ -71,6 +71,7 @@ async function calculateRecord( const projected = reconcileForm8990Projection( pending.form8990, pending, + { taxYear: meta.year, formType: "f1040" }, ); const taxpayerSsn = z.object({ taxpayer_ssn: z.string().min(1) }) .passthrough().parse(projected.sourceRecords.returnInputs.general) From da0d36f648e1c11524aabd3e038b3fcc340ec58c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:55:13 +0000 Subject: [PATCH 07/26] fix(f1040): Form 4972 and Schedule A line 16 reconcile under the return's year Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- cli/commands/export.ts | 1 + .../schedule_a_line16_disaster_source.ts | 4 +- .../schedule_a_line16_estate_source.ts | 12 +- .../schedule_j_preferential_source.test.ts | 2 +- .../form4972_amt_reconciliation.test.ts | 22 +- .../form4972/form4972_amt_reconciliation.ts | 27 +-- .../form4972_collection_reconciliation.ts | 10 +- .../form4972_estate_part2_reconciliation.ts | 4 +- .../form4972_full_share_reconciliation.ts | 8 +- ...4972_multiple_1099r_reconciliation.test.ts | 14 +- .../form4972_multiple_1099r_reconciliation.ts | 4 +- ...m4972_multiple_recipient_reconciliation.ts | 4 +- .../form4972/form4972_nua_reconciliation.ts | 8 +- ...4972_participant_collection_source.test.ts | 1 + ...rm4972_schedule_j_child_amt_source.test.ts | 6 +- .../form4972/form4972_ty2026_context.test.ts | 194 ++++++++++++++++++ .../forms/deductions/itemized/schedule_a.ts | 1 + .../schedule_a_line16_estate_statement.ts | 2 + .../2025/mef/forms/taxes/retirement/f4972.ts | 18 +- .../forms/deductions/itemized/schedule_a.ts | 1 + .../2025/pdf/forms/taxes/retirement/f4972.ts | 12 +- 21 files changed, 295 insertions(+), 60 deletions(-) create mode 100644 forms/f1040/2025/domains/taxes/retirement/form4972/form4972_ty2026_context.test.ts diff --git a/cli/commands/export.ts b/cli/commands/export.ts index 2cf4ba8fc..b347fbc2a 100644 --- a/cli/commands/export.ts +++ b/cli/commands/export.ts @@ -309,6 +309,7 @@ export async function exportForm4972PaperCommand( pending.form4972, pending, filer, + { taxYear: def.taxYear, formType: "f1040" }, "paper", ); const outPath = args.outputPath ?? diff --git a/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_disaster_source.ts b/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_disaster_source.ts index 136f36115..d488c9571 100644 --- a/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_disaster_source.ts +++ b/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_disaster_source.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { filedStandardAmount, inputSchema as standardDeductionInputSchema, @@ -73,11 +74,12 @@ export function scheduleADisasterLine16( export function scheduleALine16AllRows( pending: Readonly> | undefined, filedOtherLine16: unknown, + context: NodeContext, ): readonly ScheduleALine16Row[] { const disaster = scheduleADisasterLine16(pending); if (disaster?.increasedStandard) return disaster.rows; return [ - ...scheduleALine16Rows(pending, filedOtherLine16), + ...scheduleALine16Rows(pending, filedOtherLine16, context), ...(disaster?.rows ?? []), ]; } diff --git a/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_estate_source.ts b/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_estate_source.ts index c9adf7049..c6945dcf6 100644 --- a/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_estate_source.ts +++ b/forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_estate_source.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { EmployeeType, inputSchema as employeeExpenseSchema, @@ -13,6 +14,7 @@ import { reconcileForm4972EstatePartII } from "../../../taxes/retirement/form497 export function scheduleALine16EstateTax( pending: Readonly> | undefined, filedLine16: unknown, + context: NodeContext, ): number { const scheduleA = pending?.schedule_a; const sourceLine16 = scheduleA && typeof scheduleA === "object" && @@ -49,11 +51,8 @@ export function scheduleALine16EstateTax( "Schedule A positive line 16 needs a supported Part-II federal estate-tax source", ); } - reconcileForm4972EstatePartII(rawFields, pending); - const calculated = form4972.compute( - { taxYear: 2025, formType: "f1040" }, - fields, - ).outputs; + reconcileForm4972EstatePartII(rawFields, pending, context); + const calculated = form4972.compute(context, fields).outputs; const estateTax = calculated.find((output) => output.nodeType === "schedule_a" ) @@ -85,6 +84,7 @@ export function scheduleALine16EstateTax( export function scheduleALine16Rows( pending: Readonly> | undefined, filedLine16: unknown, + context: NodeContext, ) { const employeeExpenses = pending?.f2106 === undefined ? undefined @@ -106,7 +106,7 @@ export function scheduleALine16Rows( amount: result.scheduleATotal, }]; } - const estateTax = scheduleALine16EstateTax(pending, filedLine16); + const estateTax = scheduleALine16EstateTax(pending, filedLine16, context); return estateTax > 0 ? [{ description: "FEDERAL ESTATE TAX", diff --git a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts index 72ca6059d..c1e7c4071 100644 --- a/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts +++ b/forms/f1040/2025/domains/taxes/income-averaging/schedule-j/schedule_j_preferential_source.test.ts @@ -52,7 +52,7 @@ test("Schedule J derives preferential tax from issued dividend/farm sources and assertEquals(p.form6251.regular_tax, 31214); assertEquals(p.form6251.line11_amt, 55517); assertScheduleJSourceReturn(actual.pending, replayScheduleJ); - assertForm4972AmtJoin(830, p); + assertForm4972AmtJoin(830, p, { taxYear: 2025, formType: "f1040" }); const filer = extractFilerIdentity(p.f1040)!; const prepared = await f1040_2025.prepareReturn!(actual.pending, filer); const origins: PdfPageOrigin[] = []; diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.test.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.test.ts index 9bf26dd77..f38f428f8 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.test.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.test.ts @@ -25,34 +25,34 @@ const filed = { }; test("Form 4972 special tax is excluded exactly once from Form 6251 line 10", () => { - assertForm4972AmtJoin(500, filed); + assertForm4972AmtJoin(500, filed, { taxYear: 2025, formType: "f1040" }); }); test("Form 4972 AMT join rejects altered Form 1040 and Form 6251 sources", () => { - assertThrows(() => assertForm4972AmtJoin(501, filed)); + assertThrows(() => assertForm4972AmtJoin(501, filed, { taxYear: 2025, formType: "f1040" })); assertThrows(() => assertForm4972AmtJoin(500, { ...filed, f1040: { ...filed.f1040, line16_income_tax: 499 }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => assertForm4972AmtJoin(500, { ...filed, form6251: { ...filed.form6251, form4972_tax: 0 }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => assertForm4972AmtJoin(500, { ...filed, form6251: { ...filed.form6251, regular_tax: 4_400 }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => assertForm4972AmtJoin(500, { ...filed, form6251: { ...filed.form6251, schedule3_line1_foreign_tax_credit: 0 }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); }); @@ -102,7 +102,7 @@ test("Form 4972 special tax remains exact with Schedule J refigured AMT tax", () regular_tax: ordinaryTax2025(50_000, FilingStatus.Single), }, }; - assertForm4972AmtJoin(500, joint); + assertForm4972AmtJoin(500, joint, { taxYear: 2025, formType: "f1040" }); assertThrows(() => assertForm4972AmtJoin(500, { ...joint, @@ -110,24 +110,24 @@ test("Form 4972 special tax remains exact with Schedule J refigured AMT tax", () ...joint.form6251, regular_tax: joint.form6251.regular_tax + 500, }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => assertForm4972AmtJoin(500, { ...joint, schedule_j: { ...scheduleJ, line23: 5_709 }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => assertForm4972AmtJoin(500, { ...joint, f1040: { ...joint.f1040, filing_status: FilingStatus.MFJ }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => assertForm4972AmtJoin(500, { ...joint, f1040: { ...joint.f1040, line15_taxable_income: 50_001 }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); }); diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.ts index 306f330a4..066358a9e 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.ts @@ -2,12 +2,10 @@ import { scheduleJCurrentYearTax, scheduleJTaxSourceSchema, } from "../../../../../nodes/intermediate/forms/taxes/income-averaging/schedule_j/tax-source.ts"; -import { - AMT_EXEMPTION_2025, - AMT_PHASE_OUT_START_2025, -} from "../../../../../nodes/config/2025.ts"; +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; +import { CONFIG_BY_YEAR } from "../../../../../nodes/config/index.ts"; import { scheduleJLinesSchema } from "../../../../../nodes/intermediate/forms/taxes/income-averaging/schedule_j/calculation.ts"; -import { ordinaryTax2025 } from "../../../../../nodes/intermediate/worksheets/taxes/calculation/tax_table_2025.ts"; +import { ordinaryTaxForYear } from "../../../../../nodes/intermediate/worksheets/taxes/calculation/year_schedule.ts"; import { filingStatusSchema } from "../../../../../nodes/types.ts"; import { scheduleJChildElectionTax } from "../../income-averaging/schedule-j/schedule_j_child_tax_join.ts"; @@ -30,14 +28,17 @@ function optionalDollars(value: unknown, label: string): number { } /** - * Form 6251 (2025) line 10 removes the Form 4972 special tax from Form 1040 + * Form 6251 line 10 removes the Form 4972 special tax from Form 1040 * line 16. With Schedule J, line 10 uses tax refigured without that election; * the Form 4972 special tax is excluded from that refigured tax too. */ export function assertForm4972AmtJoin( specialTax: number, pending: Readonly>, + context: NodeContext, ): void { + const cfg = CONFIG_BY_YEAR[context.taxYear]; + if (!cfg) throw new Error(`No f1040 config for year ${context.taxYear}`); if (!Number.isInteger(specialTax) || specialTax <= 0) { throw new Error("Form 4972 AMT join needs positive computed special tax"); } @@ -63,15 +64,18 @@ export function assertForm4972AmtJoin( } const exemption = Math.max( 0, - AMT_EXEMPTION_2025[status] - - Math.floor(Math.max(0, amti - AMT_PHASE_OUT_START_2025[status]) * 0.25), + cfg.amtExemption[status] - + Math.floor( + Math.max(0, amti - cfg.amtPhaseOutStart[status]) * + cfg.amtExemptionPhaseOutRate, + ), ); if ( form6251.exemption !== exemption || form6251.taxable_excess !== Math.max(0, amti - exemption) ) { throw new Error( - "Form 6251 exemption and taxable excess differ from calculated 2025 lines 4–6", + "Form 6251 exemption and taxable excess differ from calculated lines 4–6", ); } const line10 = dollars(form6251.regular_tax, "Form 6251 line 10"); @@ -102,13 +106,12 @@ export function assertForm4972AmtJoin( (pending.schedule_j_calculation as Record | undefined) ?.current_year_tax_source; baseTax = source === undefined - ? ordinaryTax2025(taxableIncome, status) + ? ordinaryTaxForYear(taxableIncome, status, context.taxYear) : scheduleJCurrentYearTax( taxableIncome, status, scheduleJTaxSourceSchema.parse(source), - // This join checks TY2025 lines, like its AMT constants above. - 2025, + context.taxYear, ); baseTax += childTax; } diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_collection_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_collection_reconciliation.ts index cca21a1fd..6b0d35a03 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_collection_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_collection_reconciliation.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { form4972 as calculateParticipant, inputSchema as participantSchema, @@ -62,6 +63,7 @@ export function reconcileForm4972Collection( raw: Record, allPending: Readonly>, filer: FilerIdentity | undefined, + context: NodeContext, filingChannel: "mef" | "paper" = "mef", ) { const { forms, elections, source_forms } = collectionSchema.parse(raw); @@ -201,10 +203,10 @@ export function reconcileForm4972Collection( spouse: filer.spouse?.ssn, }); const ordinary = forms.reduce((sum, form) => { - const result = calculateParticipant.compute({ - taxYear: 2025, - formType: "f1040", - }, participantSchema.parse(form)); + const result = calculateParticipant.compute( + context, + participantSchema.parse(form), + ); return sum + Number( result.outputs.find((output) => output.nodeType === "f1040")?.fields diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_estate_part2_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_estate_part2_reconciliation.ts index 01567501f..0fbf0a0ad 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_estate_part2_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_estate_part2_reconciliation.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { inputSchema as f1099rSchema } from "../../../../../nodes/inputs/income/retirement/f1099r/index.ts"; import { form4972, @@ -8,6 +9,7 @@ import { export function reconcileForm4972EstatePartII( fields: Readonly>, pending: Readonly> | undefined, + context: NodeContext, ): void { if ( !(fields.federal_estate_tax !== undefined && @@ -48,7 +50,7 @@ export function reconcileForm4972EstatePartII( const parsed = form4972Schema.parse(fields); const calculated = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, parsed, ).outputs; const form = calculated.find((output) => output.nodeType === "form4972") diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_full_share_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_full_share_reconciliation.ts index 669eb74c3..1ce158ac6 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_full_share_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_full_share_reconciliation.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { reconcileParticipantCollection } from "../../../../../nodes/intermediate/forms/taxes/retirement/form4972/participant-collection.ts"; import { inputSchema as f1099rSchema } from "../../../../../nodes/inputs/income/retirement/f1099r/index.ts"; import { @@ -10,7 +11,8 @@ import { reconcileForm4972Multiple1099R } from "./form4972_multiple_1099r_reconc export function reconcileForm4972FullShare( fields: Readonly>, pending: Readonly> | undefined, - owner?: { name: string; ssn: string }, + owner: { name: string; ssn: string } | undefined, + context: NodeContext, ): void { if (fields.participant_collection_review !== undefined) { const all = f1099rSchema.parse(pending?.f1099r).f1099rs; @@ -50,7 +52,7 @@ export function reconcileForm4972FullShare( }); } if (fields.multiple_1099r !== undefined) { - reconcileForm4972Multiple1099R(fields, pending, owner); + reconcileForm4972Multiple1099R(fields, pending, owner, context); return; } if ( @@ -84,7 +86,7 @@ export function reconcileForm4972FullShare( } const computed = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, form4972Schema.parse(fields), ).outputs; const form = computed.find((output) => output.nodeType === "form4972") diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.test.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.test.ts index 5f54a977b..d0f34a23f 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.test.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.test.ts @@ -59,7 +59,7 @@ test("Form 4972 two-source lines and Form 1040 tax reconcile", () => { f1040: { form4972_tax: tax }, }; const owner = { name: "Ada Taxpayer", ssn: "123456789" }; - reconcileForm4972FullShare(fields, pending, owner); + reconcileForm4972FullShare(fields, pending, owner, { taxYear: 2025, formType: "f1040" }); const filer: FilerIdentity = { primarySSN: "123456789", fullName: "Ada Taxpayer", @@ -89,12 +89,12 @@ test("Form 4972 two-source lines and Form 1040 tax reconcile", () => { box2a_taxable_amount: 39_999, }], }, - }, owner), + }, owner, { taxYear: 2025, formType: "f1040" }), Error, "summed boxes 2a", ); assertThrows( - () => reconcileForm4972FullShare({ ...fields, line30: 1 }, pending, owner), + () => reconcileForm4972FullShare({ ...fields, line30: 1 }, pending, owner, { taxYear: 2025, formType: "f1040" }), Error, "calculated lines", ); @@ -103,7 +103,7 @@ test("Form 4972 two-source lines and Form 1040 tax reconcile", () => { reconcileForm4972FullShare(fields, pending, { ...owner, ssn: "987654321", - }), + }, { taxYear: 2025, formType: "f1040" }), Error, "matching owner", ); @@ -133,7 +133,7 @@ test("Form 4972 combines two box 3 gains under Part II and Part III", () => { f1040: { form4972_tax: tax }, }; const owner = { name: "Ada Taxpayer", ssn: "123456789" }; - reconcileForm4972FullShare(fields, pending, owner); + reconcileForm4972FullShare(fields, pending, owner, { taxYear: 2025, formType: "f1040" }); assertEquals(fields.line6, 12_000); assertEquals(fields.line7, 2_400); assertEquals(fields.line8, 58_000); @@ -170,12 +170,12 @@ test("Form 4972 combines two box 3 gains under Part II and Part III", () => { box3_capital_gain: 6_999, }], }, - }, owner), + }, owner, { taxYear: 2025, formType: "f1040" }), Error, "summed boxes 2a, 3, and 6", ); assertThrows( - () => reconcileForm4972FullShare({ ...fields, line7: 1 }, pending, owner), + () => reconcileForm4972FullShare({ ...fields, line7: 1 }, pending, owner, { taxYear: 2025, formType: "f1040" }), Error, "calculated lines", ); diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.ts index 68257016c..127ac2c85 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { sumSourceMoney } from "../../../../../nodes/intermediate/forms/taxes/retirement/form4972/source-rounding.ts"; import { allocatedSourceCents, @@ -17,6 +18,7 @@ export function reconcileForm4972Multiple1099R( fields: Readonly>, pending: Readonly> | undefined, owner: { name: string; ssn: string } | undefined, + context: NodeContext, ): void { const form = form4972Schema.parse(fields); const plan = form.multiple_1099r; @@ -157,7 +159,7 @@ export function reconcileForm4972Multiple1099R( } const computed = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, form, ).outputs; const calculated = computed.find((output) => output.nodeType === "form4972") diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_recipient_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_recipient_reconciliation.ts index 8b0c14a19..6909c2ed6 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_recipient_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_recipient_reconciliation.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { sameSourceMoney } from "../../../../../nodes/intermediate/forms/taxes/retirement/form4972/source-rounding.ts"; import { inputSchema as f1099rSchema } from "../../../../../nodes/inputs/income/retirement/f1099r/index.ts"; import { @@ -9,6 +10,7 @@ import { export function reconcileForm4972MultipleRecipients( fields: Readonly>, pending: Readonly> | undefined, + context: NodeContext, ): boolean { const source = f1099rSchema.safeParse(pending?.f1099r); const elected = source.success @@ -83,7 +85,7 @@ export function reconcileForm4972MultipleRecipients( } const parsed = form4972Schema.parse(fields); const computed = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, parsed, ); const form = computed.outputs.find((output) => output.nodeType === "form4972") diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_nua_reconciliation.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_nua_reconciliation.ts index 9611cf7c1..f2d61a333 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_nua_reconciliation.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_nua_reconciliation.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { isSourceMoney, sameSourceMoney, @@ -19,6 +20,7 @@ import { export function reconcileForm4972Nua( fields: Readonly>, pending: Readonly> | undefined, + context: NodeContext, ): void { if ( fields.elect_include_nua !== true && @@ -149,7 +151,7 @@ export function reconcileForm4972Nua( } if (sharePct < 100) { const computed = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, form4972Schema.parse(fields), ).outputs; const expected = computed.find((output) => output.nodeType === "form4972") @@ -212,7 +214,7 @@ export function reconcileForm4972Nua( ); } const computed = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, form4972Schema.parse(fields), ).outputs; const calculatedForm = computed.find((output) => @@ -287,7 +289,7 @@ export function reconcileForm4972Nua( throw new Error("Form 4972 NUA and annuity needs exact source cents"); } const computed = form4972.compute( - { taxYear: 2025, formType: "f1040" }, + context, form4972Schema.parse(fields), ); const calculatedForm = computed.outputs.find((output) => diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_participant_collection_source.test.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_participant_collection_source.test.ts index b1837587d..daa43369d 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_participant_collection_source.test.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_participant_collection_source.test.ts @@ -187,6 +187,7 @@ test("complete recipient/participant collections retain independent elections an pending.form4972, pending, filer, + { taxYear: 2025, formType: "f1040" }, ); const selected: any = structuredClone(scoped[target]); selected.fields.participant_collection_review.participant_birth_date = diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts index 07044413a..ac6919a5f 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_schedule_j_child_amt_source.test.ts @@ -75,7 +75,7 @@ test("issued participant, child, farm, dividend, W-2 and ISO sources refigure Sc assertEquals(noJ.f1040.line16_income_tax - 2768, 72381); assertEquals(noJ.f1040.form8814_tax, 135); assertScheduleJSourceReturn(result.pending, replayScheduleJ); - assertForm4972AmtJoin(2768, p); + assertForm4972AmtJoin(2768, p, { taxYear: 2025, formType: "f1040" }); const filer = extractFilerIdentity(p.f1040)!; const prepared = await f1040_2025.prepareReturn!(result.pending, filer); @@ -129,7 +129,7 @@ test("issued participant, child, farm, dividend, W-2 and ISO sources refigure Sc ) { const bad: any = structuredClone(p); change(bad); - assertThrows(() => assertForm4972AmtJoin(2768, bad)); + assertThrows(() => assertForm4972AmtJoin(2768, bad, { taxYear: 2025, formType: "f1040" })); await assertRejects(() => f1040_2025.prepareReturn!(bad, filer)); await assertRejects(() => buildPdfBytes(bad, filer, ".pdf-cache")); } @@ -155,7 +155,7 @@ test("child income just above filing threshold retains a zero-tax Form8814 with assertEquals(p.form6251.regular_tax, 71996); assertEquals(p.form6251.line11_amt, 53583); assertScheduleJSourceReturn(r.pending, replayScheduleJ); - assertForm4972AmtJoin(2768, p); + assertForm4972AmtJoin(2768, p, { taxYear: 2025, formType: "f1040" }); const filer = extractFilerIdentity(p.f1040)!; const prepared = await f1040_2025.prepareReturn!(r.pending, filer); assertEquals((prepared.bundle.xml.match(/ { + const plan = { + participant_name: "Ada Taxpayer", + participant_ssn: "123456789", + plan_reference: "Plan-A", + full_balance_statement_reference: "Administrator final-balance statement", + all_qualified_distributions_included: true as const, + }; + const sources = [30_000, 40_000].map((amount, index) => ({ + payer_name: "Plan trustee", + payer_ein: "12-3456789", + box1_gross_distribution: amount, + box2a_taxable_amount: amount, + box7_distribution_code: DistributionCode.CodeA, + box9a_pct_total: 100, + ts: TS.T, + exclude_4972: true, + source_document_reference: `1099-R-${index + 1}`, + form4972_plan: plan, + })); + const outputs = form4972.compute(ty2026, { + recipient: TS.T, + lump_sum_amount: 70_000, + multiple_1099r: { + ...plan, + source_document_references: ["1099-R-1", "1099-R-2"] as [ + string, + string, + ], + }, + born_before_1936: true, + entire_balance_distributed: true, + rolled_over_any: false, + beneficiary_distribution: false, + alternate_payee_distribution: false, + participant_five_year_member: true, + prior_election_after_1986: false, + elect_10yr_averaging: true, + }).outputs; + const fields = outputs.find((row) => row.nodeType === "form4972")!.fields; + const tax = outputs.find((row) => row.nodeType === "income_tax_calculation")! + .fields.form4972_tax; + const pending = { f1099r: { f1099rs: sources }, f1040: { form4972_tax: tax } }; + const owner = { name: "Ada Taxpayer", ssn: "123456789" }; + reconcileForm4972FullShare(fields, pending, owner, ty2026); + assertThrows(() => + reconcileForm4972FullShare({ ...fields, line30: 1 }, pending, owner, ty2026) + ); +}); + +test("TY2026 partial estate Part II builds IRS4972 and Schedule A line 16", () => { + const election = inputSchema.parse({ + recipient: "T", + born_before_1936: true, + entire_balance_distributed: true, + rolled_over_any: false, + beneficiary_distribution: true, + participant_five_year_member: false, + prior_beneficiary_election_after_1986: false, + lump_sum_amount: 20_000, + capital_gain_amount: 4_000, + recipient_share_pct: 50, + federal_estate_tax: 2_000, + partial_estate_tax_source: { + administrator_statement_reference: "estate administrator allocation", + estate_tax_return_reference: "filed estate Form 706 tax workpaper", + full_distribution_taxable_amount: 40_000, + full_distribution_federal_estate_tax: 2_000, + recipient_allocated_federal_estate_tax: 1_000, + }, + elect_capital_gain: true, + elect_10yr_averaging: false, + }); + const outputs = form4972.compute(ty2026, election).outputs; + const fields = outputs.find((row) => row.nodeType === "form4972")!.fields; + const pending = { + general: { + taxpayer_first_name: "Alex", + taxpayer_last_name: "Taxpayer", + taxpayer_ssn: "123456789", + }, + f1099r: { + f1099rs: [{ + payer_name: "Qualified Plan", + payer_ein: "123456789", + box1_gross_distribution: 20_000, + box2a_taxable_amount: 20_000, + box3_capital_gain: 4_000, + box7_distribution_code: DistributionCode.CodeA, + box9a_pct_total: 50, + ts: "T" as const, + exclude_4972: true, + }], + }, + f1040: { form4972_tax: 760, line5b_pension_taxable: 16_000 }, + }; + const xml = buildIRS4972(fields, { filer, pending, taxYear: 2026 }); + assertStringIncludes( + xml, + "760", + ); + assertThrows(() => + buildIRS4972(fields, { + filer, + pending: { ...pending, f1040: { ...pending.f1040, form4972_tax: 761 } }, + taxYear: 2026, + }) + ); + const line16 = outputs.find((row) => row.nodeType === "schedule_a")!.fields + .line_16_other_deductions; + const estate = { + ...pending, + form4972: { forms: [fields] }, + schedule_a: { line_16_other_deductions: line16 }, + f1040: { ...pending.f1040, line12e_itemized_deductions: 30_000 }, + standard_deduction: { itemized_deductions: 30_000 }, + }; + assertEquals(scheduleALine16EstateTax(estate, line16, ty2026), 800); +}); + +test("TY2026 Form 4972 AMT join uses the 50% exemption phase-out", () => { + // Rev. Proc. 2025-32 §4.10 and P.L. 119-21 §70107(b): $90,100 single + // exemption phased out at 50% above $500,000. + const filed = (exemption: number) => ({ + f1040: { + filing_status: ReturnStatus.Single, + form4972_tax: 500, + line16_income_tax: 4_500, + }, + form6251: { + filing_status: ReturnStatus.Single, + amti: 600_000, + exemption, + taxable_excess: 600_000 - exemption, + line11_amt: 0, + form4972_tax: 500, + regular_tax: 3_900, + schedule2_line1z_tax: 100, + schedule3_line1_foreign_tax_credit: 200, + form8978_negative_line14: 0, + }, + }); + assertForm4972AmtJoin(500, filed(40_100), ty2026); + assertThrows(() => assertForm4972AmtJoin(500, filed(65_100), ty2026)); +}); + +test("TY2026 participant collection recomputes each participant under TY2026", () => { + const id = participantCollectionCases[0]; + const result = f1040_2026.executeReturn(participantCollectionInputs(id)); + assertEquals(result.diagnostics, [], id); + const pending = normalizeAllPending(result.pending); + const scoped = reconcileForm4972Collection( + pending.form4972, + pending, + extractFilerIdentity(pending.f1040)!, + ty2026, + ); + assertEquals(scoped.length, pending.form4972.forms.length); +}); diff --git a/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a.ts b/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a.ts index 74dd41534..f861d7293 100644 --- a/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a.ts +++ b/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a.ts @@ -379,6 +379,7 @@ function buildIRS1040ScheduleA( const line16Rows = scheduleALine16AllRows( context?.pending, fields.line_16_other_deductions, + { taxYear: mefTaxYear(context), formType: "f1040" }, ); const line16Reference = line16Statement(context, line16Rows.length); const line16 = typeof fields.line_16_other_deductions === "number" || diff --git a/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a_line16_estate_statement.ts b/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a_line16_estate_statement.ts index 366fd0dc0..5c6debd7d 100644 --- a/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a_line16_estate_statement.ts +++ b/forms/f1040/2025/mef/forms/deductions/itemized/schedule_a_line16_estate_statement.ts @@ -1,5 +1,6 @@ import { element, elements } from "../../../../../mef/xml.ts"; import type { MefFormDescriptor } from "../../../form-descriptor.ts"; +import { mefTaxYear } from "../../../support/year-config.ts"; import { scheduleADisasterLine16, scheduleALine16AllRows, @@ -28,6 +29,7 @@ export const scheduleALine16EstateStatement: MefFormDescriptor< const rows = scheduleALine16AllRows( pending, (source as Record).line_16_other_deductions, + { taxYear: mefTaxYear(context), formType: "f1040" }, ); if (rows.length === 0) return ""; return elements("OtherMiscDeductionsStmt", [ diff --git a/forms/f1040/2025/mef/forms/taxes/retirement/f4972.ts b/forms/f1040/2025/mef/forms/taxes/retirement/f4972.ts index b4deeac42..a1eba015a 100644 --- a/forms/f1040/2025/mef/forms/taxes/retirement/f4972.ts +++ b/forms/f1040/2025/mef/forms/taxes/retirement/f4972.ts @@ -1,6 +1,7 @@ import { element, elements } from "../../../../../mef/xml.ts"; import { TS } from "../../../../../nodes/types.ts"; import type { MefBuildContext, MefFormDescriptor } from "../../../form-descriptor.ts"; +import { mefTaxYear } from "../../../support/year-config.ts"; import { reconcileForm4972Nua } from "../../../../domains/taxes/retirement/form4972/form4972_nua_reconciliation.ts"; import { reconcileForm4972MultipleRecipients } from "../../../../domains/taxes/retirement/form4972/form4972_multiple_recipient_reconciliation.ts"; import { reconcileForm4972EstatePartII } from "../../../../domains/taxes/retirement/form4972/form4972_estate_part2_reconciliation.ts"; @@ -133,12 +134,17 @@ export function buildIRS4972(fields: Input, context?: MefBuildContext): string { ) { throw new Error("Form 4972 ordinary NUA needs line 8"); } - reconcileForm4972Nua(fields, context?.pending); - reconcileForm4972EstatePartII(fields, context?.pending); - reconcileForm4972FullShare(fields, context?.pending, recipient); + const nodeContext = { + taxYear: mefTaxYear(context), + formType: "f1040", + } as const; + reconcileForm4972Nua(fields, context?.pending, nodeContext); + reconcileForm4972EstatePartII(fields, context?.pending, nodeContext); + reconcileForm4972FullShare(fields, context?.pending, recipient, nodeContext); const multipleRecipients = reconcileForm4972MultipleRecipients( fields, context?.pending, + nodeContext, ); return elements("IRS4972", [ element("PersonNm", recipient.name), @@ -205,15 +211,21 @@ export const form4972: MefFormDescriptor< if (!context?.pending) { throw new Error("Form 4972 collection needs the final pending return"); } + const nodeContext = { + taxYear: mefTaxYear(context), + formType: "f1040", + } as const; const scoped = reconcileForm4972Collection( fields, context.pending, context.filer, + nodeContext, ); if (context.pending.form6251 !== undefined) { assertForm4972AmtJoin( scoped.reduce((sum, entry) => sum + entry.tax, 0), context.pending, + nodeContext, ); } return scoped.map(({ fields: form, pending }) => 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 c22fa6e0b..08fbcbc27 100644 --- a/forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts +++ b/forms/f1040/2025/pdf/forms/deductions/itemized/schedule_a.ts @@ -405,6 +405,7 @@ export const scheduleAPdf: PdfFormDescriptor = { const line16Rows = scheduleALine16AllRows( all, input.line_16_other_deductions, + { taxYear: mefTaxYear(), formType: "f1040" }, ); const line16 = input.line_16_other_deductions === undefined && input.net_qualified_disaster_loss === undefined diff --git a/forms/f1040/2025/pdf/forms/taxes/retirement/f4972.ts b/forms/f1040/2025/pdf/forms/taxes/retirement/f4972.ts index a0fe44065..45f845211 100644 --- a/forms/f1040/2025/pdf/forms/taxes/retirement/f4972.ts +++ b/forms/f1040/2025/pdf/forms/taxes/retirement/f4972.ts @@ -12,6 +12,7 @@ import { reconcileForm4972FullShare } from "../../../../domains/taxes/retirement import { reconcileForm4972Collection } from "../../../../domains/taxes/retirement/form4972/form4972_collection_reconciliation.ts"; import { assertForm4972AmtJoin } from "../../../../domains/taxes/retirement/form4972/form4972_amt_reconciliation.ts"; import { inputSchema as f1099rSchema } from "../../../../../nodes/inputs/income/retirement/f1099r/index.ts"; +import { mefTaxYear } from "../../../../mef/support/year-config.ts"; // Field positions checked against the 2025 IRS AcroForm. Only page 1 is filed; // the NUA and death-benefit worksheets on later pages are kept for records. @@ -107,18 +108,20 @@ export function projectedFields( } return {}; } + const nodeContext = { taxYear: mefTaxYear(), formType: "f1040" } as const; const multipleRecipients = reconcileForm4972MultipleRecipients( fields, allPending, + nodeContext, ); const recipient = recipientIdentity(fields, allPending); assertElectedPdfShape(fields, allPending, multipleRecipients); - reconcileForm4972Nua(fields, allPending); - reconcileForm4972EstatePartII(fields, allPending); + reconcileForm4972Nua(fields, allPending, nodeContext); + reconcileForm4972EstatePartII(fields, allPending, nodeContext); reconcileForm4972FullShare(fields, allPending, { name: recipient.recipient_name, ssn: recipient.recipient_ssn, - }); + }, nodeContext); const printedFields = { ...fields }; if (fields.beneficiary_distribution === true) { delete printedFields.prior_election_after_1986; @@ -321,16 +324,19 @@ function collectionInstances( "Form 4972 PDF collection needs the final pending return", ); } + const nodeContext = { taxYear: mefTaxYear(), formType: "f1040" } as const; const scoped = reconcileForm4972Collection( raw, allPending, filer, + nodeContext, filingChannel, ); if (allPending.form6251 !== undefined) { assertForm4972AmtJoin( scoped.reduce((sum, entry) => sum + entry.tax, 0), allPending, + nodeContext, ); } return scoped.map(({ fields: form, pending }) => From d88ed0b20ffd7ece89e3b87adcc75cd27ea15a69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:55:13 +0000 Subject: [PATCH 08/26] fix(f1040): Form 982 QPRI threads the return's year and refuses TY2026 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../income/other/form982/form982-qpri.test.ts | 27 +++++++++++++++++ .../income/other/form982/form982-qpri.ts | 29 +++++++++++-------- .../f1040/2025/mef/forms/income/other/f982.ts | 16 ++++++++-- .../f1040/2025/pdf/forms/income/other/f982.ts | 11 +++++-- 4 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 forms/f1040/2025/domains/income/other/form982/form982-qpri.test.ts diff --git a/forms/f1040/2025/domains/income/other/form982/form982-qpri.test.ts b/forms/f1040/2025/domains/income/other/form982/form982-qpri.test.ts new file mode 100644 index 000000000..4eb8f608b --- /dev/null +++ b/forms/f1040/2025/domains/income/other/form982/form982-qpri.test.ts @@ -0,0 +1,27 @@ +import { test } from "bun:test"; +import { assertThrows } from "@std/assert"; +import { assertForm982AbsentSource, projectQpriForm982 } from "./form982-qpri.ts"; + +const ty2026 = { taxYear: 2026, formType: "f1040" } as const; +const taxable = { + f1099c: { + f1099cs: [{ + creditor_name: "Lender", + box2_cod_amount: 4_000, + routing: "taxable" as const, + }], + }, +}; + +test("TY2026 Form 982 QPRI is refused without a pre-2026 written arrangement", () => { + assertThrows( + () => projectQpriForm982({}, taxable, ty2026), + Error, + "Form 982 QPRI for TY2026 needs a written arrangement entered into before 2026", + ); +}); + +test("TY2026 absent Form 982 replays its 1099-C source under the return's year", () => { + assertForm982AbsentSource(undefined, ty2026); + assertForm982AbsentSource(taxable, ty2026); +}); diff --git a/forms/f1040/2025/domains/income/other/form982/form982-qpri.ts b/forms/f1040/2025/domains/income/other/form982/form982-qpri.ts index e8de31883..a41669448 100644 --- a/forms/f1040/2025/domains/income/other/form982/form982-qpri.ts +++ b/forms/f1040/2025/domains/income/other/form982/form982-qpri.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { CONFIG_BY_YEAR } from "../../../../../nodes/config/index.ts"; import { ExclusionType, @@ -10,14 +11,12 @@ import { } from "../../../../../nodes/inputs/income/other/f1099c/index.ts"; export function assertForm982AbsentSource( - pending?: Readonly>, + pending: Readonly> | undefined, + context: NodeContext, ): void { if (pending?.f1099c === undefined) return; const original = f1099cSchema.parse(pending.f1099c); - const hasForm982Deposit = f1099c.compute( - { taxYear: 2025, formType: "f1040" }, - original, - ).outputs.some((entry) => entry.nodeType === "form982"); + const hasForm982Deposit = f1099c.compute(context, original).outputs.some((entry) => entry.nodeType === "form982"); if (hasForm982Deposit) { throw new Error("Form 982 source exists without its required filing form"); } @@ -27,7 +26,16 @@ export function assertForm982AbsentSource( export function projectQpriForm982( raw: unknown, pending: Readonly>, + context: NodeContext, ) { + // IRC §108(a)(1)(E), as amended by P.L. 119-21, and the Form 982 line 1e + // instructions: a discharge after 2025 qualifies only under a written + // arrangement entered into before 2026, which the 1099-C source lacks. + if (context.taxYear >= 2026) { + throw new Error( + `Form 982 QPRI for TY${context.taxYear} needs a written arrangement entered into before 2026; IRC §108(a)(1)(E) excludes no later discharge without one`, + ); + } const input = form982Schema.strict().parse(raw); if (input.exclusion_type !== ExclusionType.Qpri) { throw new Error( @@ -55,10 +63,7 @@ export function projectQpriForm982( ) { throw new Error("Form 982 QPRI needs one original excluded 1099-C"); } - const sourceOutput = f1099c.compute( - { taxYear: 2025, formType: "f1040" }, - original, - ).outputs.find((entry) => entry.nodeType === "form982"); + const sourceOutput = f1099c.compute(context, original).outputs.find((entry) => entry.nodeType === "form982"); if (!sourceOutput) { throw new Error("Form 982 QPRI lacks an original 1099-C exclusion deposit"); } @@ -74,9 +79,9 @@ export function projectQpriForm982( ) { throw new Error("Form 982 QPRI differs from its original 1099-C deposit"); } - const cap = input.qpri_mfs - ? CONFIG_BY_YEAR[2025].qpriCapMfs - : CONFIG_BY_YEAR[2025].qpriCapStandard; + const cfg = CONFIG_BY_YEAR[context.taxYear]; + if (!cfg) throw new Error(`No f1040 config for year ${context.taxYear}`); + const cap = input.qpri_mfs ? cfg.qpriCapMfs : cfg.qpriCapStandard; const excluded = qpriExcludedAmount(input, cap); if (excluded <= 0) { throw new Error("Form 982 QPRI has no qualifying debt to exclude"); diff --git a/forms/f1040/2025/mef/forms/income/other/f982.ts b/forms/f1040/2025/mef/forms/income/other/f982.ts index fdd41e332..3fe159df3 100644 --- a/forms/f1040/2025/mef/forms/income/other/f982.ts +++ b/forms/f1040/2025/mef/forms/income/other/f982.ts @@ -6,6 +6,7 @@ import { import { inputSchema } from "../../../../../nodes/intermediate/forms/income/other/form982/index.ts"; import { FilingStatus } from "../../../types.ts"; import type { MefBuildContext, MefFormDescriptor } from "../../../form-descriptor.ts"; +import { mefTaxYear } from "../../../support/year-config.ts"; type Input = Partial & Record; @@ -13,11 +14,17 @@ export const FIELD_MAP: ReadonlyArray = []; function buildIRS982(raw: Input, context?: MefBuildContext): string { if (Array.isArray(raw) && raw.length === 0) { - assertForm982AbsentSource(context?.pending); + assertForm982AbsentSource(context?.pending, { + taxYear: mefTaxYear(context), + formType: "f1040", + }); return ""; } if (Object.keys(raw).length === 0) { - assertForm982AbsentSource(context?.pending); + assertForm982AbsentSource(context?.pending, { + taxYear: mefTaxYear(context), + formType: "f1040", + }); return ""; } const input = inputSchema.parse(raw); @@ -33,7 +40,10 @@ function buildIRS982(raw: Input, context?: MefBuildContext): string { if (!context?.pending) { throw new Error("Form 982 QPRI needs its original 1099-C source"); } - const projected = projectQpriForm982(raw, context.pending); + const projected = projectQpriForm982(raw, context.pending, { + taxYear: mefTaxYear(context), + formType: "f1040", + }); return elements("IRS982", [ element("DischargeOfQualifiedPrinResInd", "X"), element("TotalDischargedIndebtednessAmt", projected.line2_excluded_cod), diff --git a/forms/f1040/2025/pdf/forms/income/other/f982.ts b/forms/f1040/2025/pdf/forms/income/other/f982.ts index b822f9fde..b5f73089f 100644 --- a/forms/f1040/2025/pdf/forms/income/other/f982.ts +++ b/forms/f1040/2025/pdf/forms/income/other/f982.ts @@ -4,6 +4,7 @@ import { projectQpriForm982, } from "../../../../domains/income/other/form982/form982-qpri.ts"; import { FilingStatus, filingStatusSchema } from "../../../../../nodes/types.ts"; +import { mefTaxYear } from "../../../../mef/support/year-config.ts"; // IRS Form 982 (March 2018) AcroForm field names. This remains the current // revision for TY2025; the IRS has not published a 2025-specific Form 982. @@ -46,11 +47,17 @@ export const form982Pdf: PdfFormDescriptor = { ], projectFields: (raw, all) => { if (Object.keys(raw).length === 0) { - assertForm982AbsentSource(all); + assertForm982AbsentSource(all, { + taxYear: mefTaxYear(), + formType: "f1040", + }); return raw; } const status = filingStatusSchema.parse(all.f1040?.filing_status); - const qpri = projectQpriForm982(raw, all); + const qpri = projectQpriForm982(raw, all, { + taxYear: mefTaxYear(), + formType: "f1040", + }); if ((status === FilingStatus.MFS) !== raw.qpri_mfs) { throw new Error( "Form 982 QPRI filing-status cap conflicts with Form 1040", From c0f411788799eeaa6e2c2ca2d2d6f1f5f4da4793 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:55:13 +0000 Subject: [PATCH 09/26] fix(f1040): Form 172 workpapers thread the return's year and refuse TY2026 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../form172/form172_amt_projected_return.ts | 5 ++- .../form172/form172_current_deduction.test.ts | 27 ++++++------ .../form172/form172_current_deduction.ts | 26 ++++++++--- .../form172_current_deduction_source.test.ts | 25 ++++++----- .../form172_current_deduction_source.ts | 9 +++- .../form172/form172_modified_income.ts | 13 +++--- .../form172/form172_projected_return.test.ts | 44 ++++++++++++------- .../form172/form172_projected_return.ts | 4 +- .../form172/form172_reviewed_return.test.ts | 7 ++- .../form172/form172_reviewed_return.ts | 16 ++++--- .../form172/form172_ty2026_refusal.test.ts | 39 ++++++++++++++++ 11 files changed, 152 insertions(+), 63 deletions(-) create mode 100644 forms/f1040/2025/domains/income/business/form172/form172_ty2026_refusal.test.ts diff --git a/forms/f1040/2025/domains/income/business/form172/form172_amt_projected_return.ts b/forms/f1040/2025/domains/income/business/form172/form172_amt_projected_return.ts index d538f7da7..9e699e0e5 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_amt_projected_return.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_amt_projected_return.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { z } from "zod"; import type { SourceDocumentBytes } from "../../../../../../../core/runtime/source-documents.ts"; import { stageForm172ProjectedReturn } from "./form172_projected_return.ts"; @@ -29,6 +30,7 @@ export async function stageForm172AmtProjectedReturn( rawRegularDocuments: readonly SourceDocumentBytes[], rawAmtBinding: unknown, rawAmtDocuments: readonly SourceDocumentBytes[], + context: NodeContext, ) { // Own every input and source before either nested staging function can await. const inputs = structuredClone(rawInputs); @@ -55,9 +57,10 @@ export async function stageForm172AmtProjectedReturn( inputs, regularBinding, regularDocuments, + context, ); const amt = await stageForm172AmtReviewSource(amtBinding, amtDocuments); - if (amt.applicationYear !== 2025 || amt.originYear !== projected.originYear) { + if (amt.applicationYear !== context.taxYear || amt.originYear !== projected.originYear) { throw new Error("Current AMT review differs from the projected NOL years"); } const annual = JSON.parse( diff --git a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.test.ts b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.test.ts index 89b1eef3c..cde96cbd7 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.test.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.test.ts @@ -175,12 +175,12 @@ test("Form 172 current deduction derives opening from complete ordinary history" annual(2023), annual(2024), ], true); - const r = calculateForm172CurrentDeduction(origin(), h, current()); + const r = calculateForm172CurrentDeduction(origin(), h, current(), { taxYear: 2025, formType: "f1040" }); assertEquals(r.historyOpeningLoss, 44000); assertEquals(r.deduction, 16000); assertEquals(r.proposedSchedule1Line8a, -16000); assertEquals(r.closingLossBeforeExpiration, 28000); - assertEquals(r.carryTo2026, 28000); + assertEquals(r.carryToNextYear, 28000); assertEquals(r.publicForm1040JoinVerified, false); assertEquals(r.amtNolReconciled, false); assertEquals(r.filingReady, false); @@ -196,13 +196,14 @@ test("Form 172 current mixed deduction uses nonfarm first after prior farm carry o, h, current("mixed_farming", 30000), + { taxYear: 2025, formType: "f1040" }, v, ); assertEquals(r.historyOpeningLoss, 36000); assertEquals(r.nonfarmingDeduction, 10000); assertEquals(r.farmingDeduction, 14000); assertEquals(r.deduction, 24000); - assertEquals(r.carryTo2026, 12000); + assertEquals(r.carryToNextYear, 12000); }); test("Form 172 current deduction keeps capital absorption distinct from deductible use", () => { const h = history([ @@ -214,10 +215,10 @@ test("Form 172 current deduction keeps capital absorption distinct from deductib ], true); const c = current(); c.annual_review.capital_loss_deduction.amount = 3000; - const r = calculateForm172CurrentDeduction(origin(), h, c); + const r = calculateForm172CurrentDeduction(origin(), h, c, { taxYear: 2025, formType: "f1040" }); assertEquals(r.deduction, 16000); assertEquals(r.nonfarmingAbsorbed, 19000); - assertEquals(r.carryTo2026, 25000); + assertEquals(r.carryToNextYear, 25000); }); test("Form 172 current oldest legacy loss expires after the twentieth forward year", () => { const o = legacy(); @@ -227,12 +228,12 @@ test("Form 172 current oldest legacy loss expires after the twentieth forward ye Array.from({ length: 19 }, (_, i) => annual(2006 + i)), true, ); - const r = calculateForm172CurrentDeduction(o, h, current()); + const r = calculateForm172CurrentDeduction(o, h, current(), { taxYear: 2025, formType: "f1040" }); assertEquals(r.historyOpeningLoss, 100000); assertEquals(r.deduction, 20000); assertEquals(r.closingLossBeforeExpiration, 80000); assertEquals(r.expiredLoss, 80000); - assertEquals(r.carryTo2026, 0); + assertEquals(r.carryToNextYear, 0); }); test("Form 172 current deduction keeps exhausted history explicit without a negative zero", () => { const h = history([ @@ -242,10 +243,10 @@ test("Form 172 current deduction keeps exhausted history explicit without a nega annual(2023), annual(2024), ], true); - const r = calculateForm172CurrentDeduction(origin(), h, current()); + const r = calculateForm172CurrentDeduction(origin(), h, current(), { taxYear: 2025, formType: "f1040" }); assertEquals(r.deduction, 0); assertEquals(r.proposedSchedule1Line8a, 0); - assertEquals(r.carryTo2026, 0); + assertEquals(r.carryToNextYear, 0); }); test("Form 172 current deduction rejects year owner asserted balance and reused-source conflicts", () => { const h = history([ @@ -269,22 +270,22 @@ test("Form 172 current deduction rejects year owner asserted balance and reused- calculateForm172CurrentDeduction(origin(), h, { ...c, annual_review: { ...c.annual_review, ...patch }, - }) + }, { taxYear: 2025, formType: "f1040" }) ); } assertThrows(() => calculateForm172CurrentDeduction(origin(), h, { ...c, opening_loss: 100000, - }) + }, { taxYear: 2025, formType: "f1040" }) ); assertThrows(() => - calculateForm172CurrentDeduction(origin(), h, c, review()) + calculateForm172CurrentDeduction(origin(), h, c, { taxYear: 2025, formType: "f1040" }, review()) ); assertThrows(() => calculateForm172CurrentDeduction(origin(), h, { ...c, history_kind: "mixed_farming", - }) + }, { taxYear: 2025, formType: "f1040" }) ); }); diff --git a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts index 27ab1bac0..04c6937c4 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { z } from "zod"; import { calculateForm172CarryHistory } from "./form172_carry_history.ts"; import { calculateForm172FarmingCarryHistory } from "./form172_farming_carry_history.ts"; @@ -10,7 +11,18 @@ const schema = z.object({ annual_review: z.record(z.unknown()), }).strict(); -/** Origin -> complete history -> TY2025 deduction and carry closing workpaper. +/** Instructions for Form 172 (Rev. December 2024) and IRC §172 are the + * newest sources; no TY2026 revision is in irs/corpus/ty2026, so a later + * year is refused rather than computed under the TY2025 review contract. */ +export function assertForm172ReviewYear(context: NodeContext): void { + if (context.taxYear >= 2026) { + throw new Error( + `Form 172 NOL workpaper for TY${context.taxYear} has no current-year Form 172 source; only the TY2025 review is supported`, + ); + } +} + +/** Origin -> complete history -> current-year deduction and carry closing workpaper. * Schedule1 line8a is a proposed arithmetic join only: source authenticity, * accepted carry, AGI-dependent refigures, AMT and filing admission remain open. */ @@ -18,15 +30,17 @@ export function calculateForm172CurrentDeduction( rawOrigin: unknown, rawHistory: unknown, rawCurrentReview: unknown, + context: NodeContext, rawFarmingReview?: unknown, ) { + assertForm172ReviewYear(context); const v = schema.parse(rawCurrentReview), annual = v.annual_review; if ( - annual.tax_year !== 2025 || + annual.tax_year !== context.taxYear || Object.hasOwn(annual, "prior_absorption_records") ) { throw new Error( - "Current NOL review needs TY2025 and derives prior use from history", + "Current NOL review needs the return year and derives prior use from history", ); } const sourceHistory = z.object({ @@ -95,10 +109,10 @@ export function calculateForm172CurrentDeduction( const deduction = nonfarmingDeduction + farmingDeduction; const closingLossBeforeExpiration = history.openingLoss - nonfarmingAbsorbed - farmingAbsorbed; - const expiresNow = history.expiresAfterTaxYear === 2025; + const expiresNow = history.expiresAfterTaxYear === context.taxYear; return { originYear: history.originYear, - taxYear: 2025 as const, + taxYear: context.taxYear, historyOpeningLoss: history.openingLoss, history, currentAnnualCalculation: current, @@ -110,7 +124,7 @@ export function calculateForm172CurrentDeduction( farmingAbsorbed, closingLossBeforeExpiration, expiredLoss: expiresNow ? closingLossBeforeExpiration : 0, - carryTo2026: expiresNow ? 0 : closingLossBeforeExpiration, + carryToNextYear: expiresNow ? 0 : closingLossBeforeExpiration, currentDeductionWorkpaperArithmeticReconciled: true as const, publicForm1040JoinVerified: false as const, currentAgiDependentRefiguresVerified: false as const, diff --git a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.test.ts b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.test.ts index 1e37ab5ea..ff6153ec1 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.test.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.test.ts @@ -179,10 +179,10 @@ async function source(mixedKind = false) { test("Form172 retained current packages reproduce regular and mixed deduction and closing", async () => { for (const mixedKind of [false, true]) { const { documents, binding } = await source(mixedKind); - const r = await stageForm172CurrentDeductionSource(binding, documents); + const r = await stageForm172CurrentDeductionSource(binding, documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.historyOpeningLoss, mixedKind ? 36000 : 44000); assertEquals(r.deduction, mixedKind ? 24000 : 16000); - assertEquals(r.carryTo2026, mixedKind ? 12000 : 28000); + assertEquals(r.carryToNextYear, mixedKind ? 12000 : 28000); assertEquals(r.review_package_manifest.length, mixedKind ? 4 : 3); assertEquals(r.reviewPackageBytesVerified, true); for ( @@ -208,23 +208,24 @@ test("Form172 current source rejects every tampered missing duplicate and extra })); changed[i].bytes[0] = 32; await assertRejects(() => - stageForm172CurrentDeductionSource(binding, changed) + stageForm172CurrentDeductionSource(binding, changed, { taxYear: 2025, formType: "f1040" }) ); await assertRejects(() => stageForm172CurrentDeductionSource( binding, documents.filter((_, n) => n !== i), + { taxYear: 2025, formType: "f1040" }, ) ); } await assertRejects(() => - stageForm172CurrentDeductionSource(binding, [...documents, documents[0]]) + stageForm172CurrentDeductionSource(binding, [...documents, documents[0]], { taxYear: 2025, formType: "f1040" }) ); await assertRejects(() => stageForm172CurrentDeductionSource(binding, [...documents, { ...documents[0], reference: "extra", - }]) + }], { taxYear: 2025, formType: "f1040" }) ); } }); @@ -242,7 +243,7 @@ test("Form172 current source rejects owner year kind and asserted result substit ] ) { await assertRejects(() => - stageForm172CurrentDeductionSource({ ...binding, ...patch }, documents) + stageForm172CurrentDeductionSource({ ...binding, ...patch }, documents, { taxYear: 2025, formType: "f1040" }) ); } const mixedSource = await source(true); @@ -250,7 +251,7 @@ test("Form172 current source rejects owner year kind and asserted result substit stageForm172CurrentDeductionSource({ ...mixedSource.binding, history_kind: "regular", - }, mixedSource.documents) + }, mixedSource.documents, { taxYear: 2025, formType: "f1040" }) ); }); test("Form172 current source recomputes current and historical facts after replacement digest", async () => { @@ -279,7 +280,7 @@ test("Form172 current source recomputes current and historical facts after repla }, [documents[0], documents[1], { reference: binding.current_review.reference, bytes, - }]) + }], { taxYear: 2025, formType: "f1040" }) ); } const badHistory = JSON.parse(new TextDecoder().decode(documents[1].bytes)); @@ -293,7 +294,7 @@ test("Form172 current source recomputes current and historical facts after repla documents[0], { reference: binding.history.reference, bytes }, documents[2], - ]) + ], { taxYear: 2025, formType: "f1040" }) ); }); test("Form172 current source rejects noncanonical duplicate keys and invalid UTF8", async () => { @@ -320,13 +321,13 @@ test("Form172 current source rejects noncanonical duplicate keys and invalid UTF }, [documents[0], documents[1], { reference: binding.current_review.reference, bytes, - }]) + }], { taxYear: 2025, formType: "f1040" }) ); } }); test("Form172 current source owns bindings and every byte before first await", async () => { const { documents, binding } = await source(true); - const pending = stageForm172CurrentDeductionSource(binding, documents); + const pending = stageForm172CurrentDeductionSource(binding, documents, { taxYear: 2025, formType: "f1040" }); binding.taxpayer_ssn = "999887777"; binding.current_review.sha256 = "0".repeat(64); binding.farming_review!.reference = "changed"; @@ -336,5 +337,5 @@ test("Form172 current source owns bindings and every byte before first await", a } const r = await pending; assertEquals(r.deduction, 24000); - assertEquals(r.carryTo2026, 12000); + assertEquals(r.carryToNextYear, 12000); }); diff --git a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.ts b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.ts index e44bc8c28..0a52c15fc 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_current_deduction_source.ts @@ -1,10 +1,14 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { z } from "zod"; import { type SourceDocumentBytes, VerifiedSourceDocuments, } from "../../../../../../../core/runtime/source-documents.ts"; import { parseReviewedNolOrigin } from "./form172_nol_origin.ts"; -import { calculateForm172CurrentDeduction } from "./form172_current_deduction.ts"; +import { + assertForm172ReviewYear, + calculateForm172CurrentDeduction, +} from "./form172_current_deduction.ts"; const claim = z.object({ reference: z.string().trim().min(1), @@ -46,7 +50,9 @@ function parsePackage(bytes: Uint8Array): Record { export async function stageForm172CurrentDeductionSource( rawBinding: unknown, rawDocuments: readonly SourceDocumentBytes[], + context: NodeContext, ) { + assertForm172ReviewYear(context); const binding = bindingSchema.parse(rawBinding); const documents = rawDocuments.map((d) => ({ reference: d.reference, @@ -90,6 +96,7 @@ export async function stageForm172CurrentDeductionSource( origin, history, current, + context, farm, ); return { diff --git a/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts b/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts index f1e91ccb2..cc098301b 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts @@ -1,3 +1,5 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; +import { assertForm172ReviewYear } from "./form172_current_deduction.ts"; import { z } from "zod"; import { form172CarryAbsorptionSchema } from "./form172_carry_absorption.ts"; import { execute, type ExecuteResult } from "../../../../../../../core/runtime/executor.ts"; @@ -25,12 +27,14 @@ export function reconcileForm172ModifiedIncome( rawAnnual: unknown, before: Record>, expectedModifiedAgi: number, + context: NodeContext, ) { + assertForm172ReviewYear(context); const annual = form172CarryAbsorptionSchema.parse({ ...z.record(z.unknown()).parse(rawAnnual), prior_absorption_records: [], }); - if (annual.tax_year !== 2025 || !Number.isSafeInteger(expectedModifiedAgi)) { + if (annual.tax_year !== context.taxYear || !Number.isSafeInteger(expectedModifiedAgi)) { throw new Error( "Modified NOL income needs the current reviewed year and exact AGI", ); @@ -61,10 +65,9 @@ export function reconcileForm172ModifiedIncome( } } const modifiedRegistry = { ...registry, agi_aggregator: new ModifiedAgi() }; - const modifiedContext = { taxYear: 2025, formType: "f1040" } as const; const graph = ( source: Record, - ctx: Parameters[3] = modifiedContext, + ctx: Parameters[3] = context, ) => execute( buildExecutionPlan(modifiedRegistry), @@ -75,11 +78,11 @@ export function reconcileForm172ModifiedIncome( const execution: ExecuteResult = inputs.form8990 === undefined ? executePreQefSourceReturn( structuredClone(inputs), - modifiedContext, + context, true, graph, ) - : executeForm172Form8990Return(structuredClone(inputs), modifiedContext, graph).execution; + : executeForm172Form8990Return(structuredClone(inputs), context, graph).execution; if (execution.diagnostics.length) { throw new Error("Modified NOL income needs a successful source graph"); } diff --git a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts index 43029ce28..1bc0b3cd1 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.test.ts @@ -207,6 +207,7 @@ test("Form172 projection sends retained deduction through Schedule1 AGI and regu f.inputs, f.binding, f.documents, + { taxYear: 2025, formType: "f1040" }, ); assertEquals(r.deduction, 27400); assertEquals(r.projected_schedule1.line8a_nol_deduction, 27400); @@ -253,6 +254,7 @@ test("Form172 projection rejects detached NOL shortcuts", async () => { }, f.binding, f.documents, + { taxYear: 2025, formType: "f1040" }, ) ); await assertRejects(() => @@ -260,6 +262,7 @@ test("Form172 projection rejects detached NOL shortcuts", async () => { { ...f.inputs, schedule1: { line8a_nol_deduction: 27400 } }, f.binding, f.documents, + { taxYear: 2025, formType: "f1040" }, ) ); }); @@ -294,7 +297,7 @@ async function seniorFixture(amount = 6000, seniorAmount = amount) { } test("Form172 retained senior Schedule1-A joins public deduction and lowers current NOL capacity", async () => { const f = await seniorFixture(); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.current_form1040_before_nol.line12c_deduction_total, 17750); assertEquals( r.current_form1040_before_nol.line13b_additional_deductions, @@ -303,7 +306,7 @@ test("Form172 retained senior Schedule1-A joins public deduction and lowers curr assertEquals(r.current_form1040_before_nol.line15_taxable_income, 26250); assertEquals(r.deduction, 21000); assertEquals(r.currentAnnualCalculation.taxableWithoutNolQbi250, 26250); - assertEquals(r.carryTo2026, 17000); + assertEquals(r.carryToNextYear, 17000); assertEquals(r.currentAnnualCalculation.absorbed, 27000); assertEquals( r.currentAnnualCalculation.seniorDeductionAbsorptionAddback, @@ -320,7 +323,7 @@ test("Form172 retained senior Schedule1-A joins public deduction and lowers curr test("Form172 current source rejects an internally consistent wrong senior deduction", async () => { for (const f of [await seniorFixture(5000), await seniorFixture(6000, 0)]) { await assertRejects( - () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents), + () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }), Error, "Schedule1-A", ); @@ -340,7 +343,7 @@ test("Form172 NOL projection reruns education MAGI and credit limitation through const bytes = new TextEncoder().encode(JSON.stringify(review)); f.documents[2] = { reference: review.reference, bytes }; f.binding.current_review.sha256 = await sha(bytes); - const r = await stageForm172ProjectedReturn(inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.current_form1040_before_nol.line11_agi, 81000); assertEquals(r.deduction, 44000); assertEquals(r.projected_form1040.line11_agi, 37000); @@ -481,7 +484,7 @@ test("Form172 NOL projection preserves source-dependent adoption finalizer and r const bytes = new TextEncoder().encode(JSON.stringify(review)); f.documents[2] = { reference: review.reference, bytes }; f.binding.current_review.sha256 = await sha(bytes); - const r = await stageForm172ProjectedReturn(inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.deduction, 44000); assertEquals(r.projected_form1040.line11_agi, 76000); assertEquals(r.projected_form1040.line15_taxable_income, 60250); @@ -591,6 +594,7 @@ test("Form172 retained independent AMT review matches ordinary mixed and senior f.documents, a.binding, a.documents, + { taxYear: 2025, formType: "f1040" }, ); assertEquals(r.currentAmtTentativeGraphReconciled, true); assertEquals(r.independent_amt_review.originAmtNol, 80000); @@ -624,6 +628,7 @@ test("Form172 AMT graph rejects offsetting component substitutions despite an un f.documents, a.binding, a.documents, + { taxYear: 2025, formType: "f1040" }, ), Error, "line2g differs", @@ -645,6 +650,7 @@ test("Form172 AMT graph rejects internally consistent Form1040 operands differin f.documents, a.binding, a.documents, + { taxYear: 2025, formType: "f1040" }, ), Error, "AGI differs", @@ -666,6 +672,7 @@ test("Form172 AMT graph requires the identical retained regular origin in both p f.documents, a.binding, a.documents, + { taxYear: 2025, formType: "f1040" }, ), Error, "identical loss-year source", @@ -680,6 +687,7 @@ test("Form172 AMT composition owns both bindings all source bytes and public inp f.documents, a.binding, a.documents, + { taxYear: 2025, formType: "f1040" }, ); f.inputs.w2 = []; f.binding.origin.sha256 = "0".repeat(64); @@ -700,7 +708,7 @@ test("Form172 post-NOL senior phaseout reruns before the retained finalizer", as const bytes = new TextEncoder().encode(JSON.stringify(review)); f.documents[2].bytes = bytes; f.binding.current_review.sha256 = await sha(bytes); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals( r.current_form1040_before_nol.schedule1a_line37_senior_deduction, 1500, @@ -730,7 +738,7 @@ test("Form172 full graph refigures source student-loan interest before finalized const bytes = new TextEncoder().encode(JSON.stringify(f.currentReview)); f.documents[2].bytes = bytes; f.binding.current_review.sha256 = await sha(bytes); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals( r.public_pending_before_nol.schedule1?.line21_student_loan_interest, 833, @@ -754,7 +762,7 @@ test("Form172 ordinary NOL refigures QBI taxable-income limit without reducing c const bytes = new TextEncoder().encode(JSON.stringify(f.currentReview)); f.documents[2].bytes = bytes; f.binding.current_review.sha256 = await sha(bytes); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.current_form1040_before_nol.line11_agi, 50000); assertEquals(r.current_form1040_before_nol.line13_qbi_deduction, 6000); assertEquals(r.deduction, 27400); @@ -861,7 +869,7 @@ async function interestFixture() { } test("Form172 retained NOL composes both sourced interest passes with ATI line9 restoration", async () => { const f = await interestFixture(); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); const composed = r.form8990_nol_composition!; assertEquals(r.deduction, 44000); assertEquals(composed.limit.line9, 44000); @@ -936,7 +944,7 @@ test("Form172 interest composition rejects changed current workpaper and source f.documents[2] = { reference: f.currentReview.reference, bytes }; f.binding.current_review.sha256 = await sha(bytes); await assertRejects(() => - stageForm172ProjectedReturn(f.inputs, f.binding, f.documents) + stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }) ); const valid = await interestFixture(); const bad = structuredClone(interestSourceRecords); @@ -946,6 +954,7 @@ test("Form172 interest composition rejects changed current workpaper and source { ...valid.inputs, form8990: bad }, valid.binding, valid.documents, + { taxYear: 2025, formType: "f1040" }, ) ); }); @@ -973,6 +982,7 @@ test("Form172 sourced interest and retained AMT review reconcile raw tentative t f.documents, a.binding, a.documents, + { taxYear: 2025, formType: "f1040" }, ); assertEquals(r.form8990NolOrderingReconciled, true); assertEquals(r.currentAmtTentativeTotalReconciled, true); @@ -1026,7 +1036,7 @@ test("Form172 modified carry income replays capital-loss and student-interest AG f.binding.current_review.sha256 = await sha(bytes); }; await update(); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.currentModifiedIncomeGraphReconciled, true); assertEquals(r.currentAnnualCalculation.modifiedAgi, 94167); assertEquals( @@ -1042,7 +1052,7 @@ test("Form172 modified carry income replays capital-loss and student-interest AG a.agi_refigures = []; await update(); await assertRejects(() => - stageForm172ProjectedReturn(f.inputs, f.binding, f.documents) + stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }) ); }); @@ -1084,7 +1094,7 @@ test("Form172 modified carry income refigures SSA independently of post-NOL tax" f.binding.current_review.sha256 = await sha(bytes); }; await update(); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.currentAnnualCalculation.modifiedAgi, 23000); assertEquals(r.modified_income_pending.f1040.line6b_ss_taxable, 1000); assertEquals(r.current_form1040_before_nol.line6b_ss_taxable ?? 0, 0); @@ -1096,7 +1106,7 @@ test("Form172 modified carry income refigures SSA independently of post-NOL tax" a.agi_refigures[0].after = 1100; await update(); await assertRejects( - () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents), + () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }), Error, "source component", ); @@ -1105,7 +1115,7 @@ test("Form172 modified carry income refigures SSA independently of post-NOL tax" a.section1202_exclusion.amount = 100; await update(); await assertRejects( - () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents), + () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }), Error, "section1202", ); @@ -1143,7 +1153,7 @@ test("Form172 carry-use medical deduction stays distinct from post-NOL itemized f.binding.current_review.sha256 = await sha(bytes); }; await update(); - const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents); + const r = await stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }); assertEquals(r.current_form1040_before_nol.line12c_deduction_total, 16475); assertEquals(r.currentAnnualCalculation.modifiedAgi, 50000); assertEquals(r.currentAnnualCalculation.refiguredDeduction, 16250); @@ -1160,7 +1170,7 @@ test("Form172 carry-use medical deduction stays distinct from post-NOL itemized a.refigured_itemized_deduction.amount = 16475; await update(); await assertRejects( - () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents), + () => stageForm172ProjectedReturn(f.inputs, f.binding, f.documents, { taxYear: 2025, formType: "f1040" }), Error, "deductions", ); diff --git a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts index 63a836501..fe90a9dd0 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import type { SourceDocumentBytes } from "../../../../../../../core/runtime/source-documents.ts"; import { execute } from "../../../../../../../core/runtime/executor.ts"; import { buildExecutionPlan } from "../../../../../../../core/runtime/planner.ts"; @@ -20,13 +21,14 @@ export async function stageForm172ProjectedReturn( rawInputs: Readonly>, rawBinding: unknown, rawDocuments: readonly SourceDocumentBytes[], + nolContext: NodeContext, ) { const inputs = structuredClone(rawInputs); - const nolContext = { taxYear: 2025, formType: "f1040" } as const; const staged = await stageForm172ReviewedReturnCalculation( inputs, rawBinding, rawDocuments, + nolContext, inputs.form8990 !== undefined ? (source) => executeForm172Form8990Return(source, nolContext).execution : undefined, diff --git a/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.test.ts b/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.test.ts index 0cb5ccc02..e8525f498 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.test.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.test.ts @@ -203,11 +203,12 @@ test("Form172 current workpaper matches public wages deduction and retained fina f.inputs, f.binding, f.documents, + { taxYear: 2025, formType: "f1040" }, ); assertEquals(r.current_form1040_before_nol.line11_agi, 50000); assertEquals(r.current_form1040_before_nol.line15_taxable_income, 34250); assertEquals(r.deduction, 27400); - assertEquals(r.carryTo2026, mixedKind ? 8600 : 16600); + assertEquals(r.carryToNextYear, mixedKind ? 8600 : 16600); assertEquals(r.currentReturnStartingAmountsReconciled, true); assertEquals(r.publicForm1040JoinVerified, false); assertEquals(r.currentAgiDependentRefiguresVerified, false); @@ -233,6 +234,7 @@ test("Form172 current workpaper rejects public income identity status and detach { ...f.inputs, ...patch }, f.binding, f.documents, + { taxYear: 2025, formType: "f1040" }, ) ); } @@ -267,7 +269,7 @@ test("Form172 current workpaper rejects internally valid source amounts differin }, [f.documents[0], f.documents[1], { reference: f.currentReview.reference, bytes, - }]) + }], { taxYear: 2025, formType: "f1040" }) ); } }); @@ -277,6 +279,7 @@ test("Form172 current return owns public input and source bytes during digest aw f.inputs, f.binding, f.documents, + { taxYear: 2025, formType: "f1040" }, ); f.inputs.general = {}; f.inputs.w2 = []; diff --git a/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.ts b/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.ts index 3548552e8..0c4685451 100644 --- a/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.ts +++ b/forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.ts @@ -1,3 +1,5 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; +import { assertForm172ReviewYear } from "./form172_current_deduction.ts"; import type { SourceDocumentBytes } from "../../../../../../../core/runtime/source-documents.ts"; import { reconcileForm172ModifiedIncome } from "./form172_modified_income.ts"; import type { ExecuteResult } from "../../../../../../../core/runtime/executor.ts"; @@ -29,9 +31,11 @@ export async function stageForm172ReviewedReturnCalculation( rawInputs: Readonly>, rawBinding: unknown, rawDocuments: readonly SourceDocumentBytes[], + context: NodeContext, executeStartingReturn: (inputs: Record) => ExecuteResult = f1040_2025.executeReturn, ) { + assertForm172ReviewYear(context); const inputs = structuredClone(rawInputs); const documents = rawDocuments.map((d) => ({ reference: d.reference, @@ -52,7 +56,11 @@ export async function stageForm172ReviewedReturnCalculation( throw new Error(`NOL starting return rejects detached ${key} inputs`); } } - const staged = await stageForm172CurrentDeductionSource(binding, documents); + const staged = await stageForm172CurrentDeductionSource( + binding, + documents, + context, + ); // The staging function already verifies exact canonical bytes and this claim. const claim = (binding as { current_review: { reference: string } }).current_review; @@ -86,10 +94,7 @@ export async function stageForm172ReviewedReturnCalculation( ); } const replayInput = f1040.inputSchema.parse(execution.replayInputs.f1040); - const replay = f1040.compute( - { taxYear: 2025, formType: "f1040" }, - replayInput, - ).outputs.find((o) => o.nodeType === "f1040")?.fields; + const replay = f1040.compute(context, replayInput).outputs.find((o) => o.nodeType === "f1040")?.fields; if (!replay) throw new Error("NOL finalizer replay is missing"); for (const key of Object.keys(replay).filter((k) => /^line\d/.test(k))) { if (JSON.stringify(replay[key]) !== JSON.stringify(before[key])) { @@ -156,6 +161,7 @@ export async function stageForm172ReviewedReturnCalculation( annual, pending, staged.currentAnnualCalculation.modifiedAgi, + context, ); return { ...staged, diff --git a/forms/f1040/2025/domains/income/business/form172/form172_ty2026_refusal.test.ts b/forms/f1040/2025/domains/income/business/form172/form172_ty2026_refusal.test.ts new file mode 100644 index 000000000..cb8dd6dc4 --- /dev/null +++ b/forms/f1040/2025/domains/income/business/form172/form172_ty2026_refusal.test.ts @@ -0,0 +1,39 @@ +import { test } from "bun:test"; +import { assertRejects, assertThrows } from "@std/assert"; +import { calculateForm172CurrentDeduction } from "./form172_current_deduction.ts"; +import { stageForm172CurrentDeductionSource } from "./form172_current_deduction_source.ts"; +import { reconcileForm172ModifiedIncome } from "./form172_modified_income.ts"; +import { stageForm172ReviewedReturnCalculation } from "./form172_reviewed_return.ts"; +import { stageForm172ProjectedReturn } from "./form172_projected_return.ts"; + +// Instructions for Form 172 (Rev. December 2024) are the newest NOL source. +const ty2026 = { taxYear: 2026, formType: "f1040" } as const; +const refusal = "Form 172 NOL workpaper for TY2026"; + +test("TY2026 Form 172 NOL workpapers are refused, not computed as TY2025", async () => { + assertThrows( + () => calculateForm172CurrentDeduction({}, {}, {}, ty2026), + Error, + refusal, + ); + assertThrows( + () => reconcileForm172ModifiedIncome({}, {}, {}, 0, ty2026), + Error, + refusal, + ); + await assertRejects( + () => stageForm172CurrentDeductionSource({}, [], ty2026), + Error, + refusal, + ); + await assertRejects( + () => stageForm172ReviewedReturnCalculation({}, {}, [], ty2026), + Error, + refusal, + ); + await assertRejects( + () => stageForm172ProjectedReturn({}, {}, [], ty2026), + Error, + refusal, + ); +}); From 61605677e6da02f85a06072ddc2261c00788d1b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:55:13 +0000 Subject: [PATCH 10/26] fix(f1040): Form 8621 replays take the return's context, no TY2025 default Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../form8621_1294_8839_refigure.test.ts | 12 +++---- .../form8621_1294_8863_refigure.test.ts | 8 ++--- .../form8621/form8621_1294_refigure.ts | 4 +-- .../foreign/schedule1-form8621-source.ts | 2 +- .../schedule1_form8621_source_replay.test.ts | 6 ++-- .../foreign/schedule1_form8621_ty2026.test.ts | 35 +++++++++++++++++++ 6 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 forms/f1040/2025/domains/income/foreign/schedule1_form8621_ty2026.test.ts diff --git a/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8839_refigure.test.ts b/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8839_refigure.test.ts index 0b27660a6..d30243b40 100644 --- a/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8839_refigure.test.ts +++ b/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8839_refigure.test.ts @@ -112,7 +112,7 @@ test("Form 8621 Election B composes the reviewed Form 8839 staged return in both const result = f1040_2025.executeReturn(input); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); const filed = pending.f1040! as Record; const without = f1040_2025.executeReturn({ ...input, @@ -174,7 +174,7 @@ test("Form 8621 Election B composes the reviewed Form 8839 staged return in both }; marker.source_inputs.w2[0].box1_wages += 1; assertThrows( - () => assertForm8621QefRefigureSource(tampered), + () => assertForm8621QefRefigureSource(tampered, { taxYear: 2025, formType: "f1040" }), Error, "differs from full source", ); @@ -256,7 +256,7 @@ test("Form 8621 Election B keeps an actual fully used adoption credit when only assertEquals(filed.form8621_1294_total_tax_before_deferral, 85); assertEquals(filed.form8621_1294_deferred_tax, 85); assertEquals(filed.line24_total_tax, 0); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); const bundle = await buildMefBundle(pending, { filer: base.filer, attachments: base.attachments!, @@ -307,7 +307,7 @@ test("Form 8621 Election B records zero deferred tax when the full-return tax is assertEquals(filed.form8621_1294_counterfactual_total_tax, 7_455); assertEquals(filed.form8621_1294_deferred_tax, 0); assertEquals(filed.line24_total_tax, 7_455); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); const bundle = await buildMefBundle(pending, { filer: base.filer, attachments: base.attachments!, @@ -360,7 +360,7 @@ test("two independent Election B holdings reconcile per-fund tax to the full ado const result = f1040_2025.executeReturn(inputs); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); const filed = pending.f1040! as Record; assertEquals(filed.form8621_1294_total_tax_before_deferral, 8_335); assertEquals(filed.form8621_1294_counterfactual_total_tax, 7_455); @@ -524,7 +524,7 @@ test("three independent Election B holdings retain separate sourced copies", asy const result = f1040_2025.executeReturn(inputs); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); const filed = pending.f1040! as Record; assertEquals(filed.form8621_1294_total_tax_before_deferral, 8_775); assertEquals(filed.form8621_1294_counterfactual_total_tax, 7_455); diff --git a/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8863_refigure.test.ts b/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8863_refigure.test.ts index fdd986bfc..f7f571280 100644 --- a/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8863_refigure.test.ts +++ b/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_8863_refigure.test.ts @@ -89,7 +89,7 @@ test("QEF Election B refigures actual education MAGI, phaseout, and credit capac const result = f1040_2025.executeReturn(input); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); const filed = pending.f1040 as Record; assertEquals(filed.line11_agi, 83_000); assertEquals(filed.line18_total_tax_before_credits, 9_715); @@ -295,7 +295,7 @@ test("issued Form 1098-T and payments retain an LLC credit-limit refigure", asyn const result = f1040_2025.executeReturn(input); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); assertEquals(pending.f1040?.line11_agi, 19_000); assertEquals(pending.f1040?.line18_total_tax_before_credits, 328); assertEquals(pending.schedule3?.line3_education_credit, 328); @@ -368,7 +368,7 @@ test("dependent education and Schedule 8812 worksheets refigure together", async const result = f1040_2025.executeReturn(input); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); assertEquals(pending.f1040?.line11_agi, 77_000); assertEquals(pending.f1040?.line18_total_tax_before_credits, 8_395); assertEquals(pending.schedule3?.line3_education_credit, 3_000); @@ -441,7 +441,7 @@ test("QEF Election B replays sourced Form 3800 tax-limited business credit", asy const result = f1040_2025.executeReturn(input); assertEquals(result.diagnostics, []); const pending = buildPending(result.pending); - assertForm8621QefRefigureSource(pending); + assertForm8621QefRefigureSource(pending, { taxYear: 2025, formType: "f1040" }); assertEquals(pending.f1040?.line18_total_tax_before_credits, 18_347); assertEquals(pending.schedule3?.line6a_total, 9_533); assertEquals(pending.f1040?.form8621_1294_total_tax_before_deferral, 8_814); diff --git a/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_refigure.ts b/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_refigure.ts index 3fea7bc78..55847ddd3 100644 --- a/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_refigure.ts +++ b/forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_refigure.ts @@ -5,8 +5,6 @@ import { f1040 } from "../../../../../nodes/outputs/general/return-assembly/f104 import { buildPending } from "../../../../mef/execution/pending.ts"; import { executePreQefSourceReturn } from "../../../../return-processing/staged_source_return.ts"; -const sourceReplayContext = { taxYear: 2025, formType: "f1040" } as const; - function record(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -222,7 +220,7 @@ export function applyForm8621QefRefigure( /** Both exporters independently rerun the retained full and counterfactual returns. */ export function assertForm8621QefRefigureSource( pending: Readonly>, - context: NodeContext = sourceReplayContext, + context: NodeContext, ): void { const marker = record(pending.form8621_1294_refigure); if (!marker) { diff --git a/forms/f1040/2025/domains/income/foreign/schedule1-form8621-source.ts b/forms/f1040/2025/domains/income/foreign/schedule1-form8621-source.ts index 79ff911c1..b4b026bde 100644 --- a/forms/f1040/2025/domains/income/foreign/schedule1-form8621-source.ts +++ b/forms/f1040/2025/domains/income/foreign/schedule1-form8621-source.ts @@ -26,7 +26,7 @@ function amount(value: unknown): number { /** Replay the retained PFIC holdings before either final return exporter runs. */ export function assertSchedule1Form8621Source( pending: Readonly>, - context: NodeContext = { taxYear: 2025, formType: "f1040" }, + context: NodeContext, ): void { const schedule1 = record(pending.schedule1); const form = record(pending.form8621); diff --git a/forms/f1040/2025/domains/income/foreign/schedule1_form8621_source_replay.test.ts b/forms/f1040/2025/domains/income/foreign/schedule1_form8621_source_replay.test.ts index 57e8f9154..373418ab8 100644 --- a/forms/f1040/2025/domains/income/foreign/schedule1_form8621_source_replay.test.ts +++ b/forms/f1040/2025/domains/income/foreign/schedule1_form8621_source_replay.test.ts @@ -72,7 +72,7 @@ test("Schedule 1 Form 8621 source replay retains QEF, MTM, and section 1291 tota "line8z_form8621_section1291", ] as const; for (const [index, pending] of holdings.entries()) { - assertSchedule1Form8621Source(pending); + assertSchedule1Form8621Source(pending, { taxYear: 2025, formType: "f1040" }); const key = keys[index]; const schedule1 = pending.schedule1 as Record; assertThrows( @@ -80,7 +80,7 @@ test("Schedule 1 Form 8621 source replay retains QEF, MTM, and section 1291 tota assertSchedule1Form8621Source({ ...pending, schedule1: { ...schedule1, [key]: schedule1[key] + 1 }, - }), + }, { taxYear: 2025, formType: "f1040" }), Error, "differs from retained PFIC holdings", ); @@ -99,7 +99,7 @@ test("Schedule 1 Form 8621 source replay retains QEF, MTM, and section 1291 tota item: { ...form.items[0].item, qef_ordinary_income: 2_001 }, }], }, - }), + }, { taxYear: 2025, formType: "f1040" }), Error, "differs from retained PFIC holdings", ); diff --git a/forms/f1040/2025/domains/income/foreign/schedule1_form8621_ty2026.test.ts b/forms/f1040/2025/domains/income/foreign/schedule1_form8621_ty2026.test.ts new file mode 100644 index 000000000..85313a655 --- /dev/null +++ b/forms/f1040/2025/domains/income/foreign/schedule1_form8621_ty2026.test.ts @@ -0,0 +1,35 @@ +import { test } from "bun:test"; +import { assertThrows } from "@std/assert"; +import { assertSchedule1Form8621Source } from "./schedule1-form8621-source.ts"; +import { f8621, itemSchema, PficRegime } from "../../../../nodes/inputs/income/foreign/f8621/index.ts"; + +const ty2026 = { taxYear: 2026, formType: "f1040" } as const; + +test("TY2026 Schedule 1 Form 8621 source replays under the return's year", () => { + for ( + const item of [ + { regime: PficRegime.QEF, qef_ordinary_income: 2_000, qef_capital_gain: 0 }, + { regime: PficRegime.MTM, mtm_adjusted_basis_at_year_end: 9_000 }, + ] + ) { + const result = f8621.compute(ty2026, { + f8621s: [itemSchema.parse({ + company_name: "Offshore Fund Ltd", + company_ein_or_ref: "FUND001", + country_of_incorporation: "Ireland", + shares_owned: 100, + fmv_at_year_end: 10_000, + ...item, + })], + }); + const pending = { + form8621: result.outputs.find((o) => o.nodeType === "form8621")?.fields, + schedule1: result.outputs.find((o) => o.nodeType === "schedule1") + ?.fields, + }; + assertSchedule1Form8621Source(pending, ty2026); + assertThrows(() => + assertSchedule1Form8621Source({ ...pending, schedule1: {} }, ty2026) + ); + } +}); From 75bfb2fd97eadfb0863ee366765ccc94a6d49eb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:55:14 +0000 Subject: [PATCH 11/26] fix(f1040): passive S-corp copies replay Form 8582 and AGI under the return's year Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../earned-income/earned-income/eic-source.ts | 2 +- .../eic_passive_s_corp_loss_copies.test.ts | 5 ++++- .../eic_passive_s_corp_loss_ty2026.test.ts | 15 +++++++++++++++ .../general/filing/form8886/k1-activity-source.ts | 6 +++++- .../business/form7203/form7203-owned-return.ts | 4 +++- .../income/business/passive-s-corp-loss-copies.ts | 12 ++++++------ .../schedule-e/schedule-e-k1-part2.ts | 4 +++- forms/f1040/2025/mef/builder.ts | 4 ++-- .../deductions/business/f8995/f8995-route.ts | 5 ++++- .../mef/forms/income/business/f7203_stock_loss.ts | 1 + .../2025/mef/forms/income/business/f8582/f8582.ts | 5 ++++- .../forms/income/rental-passthrough/schedule_e.ts | 5 ++++- .../rental-passthrough/schedule_e_stock_loss.ts | 5 ++++- forms/f1040/2025/pdf/builder.ts | 14 +++++++++++--- .../pdf/forms/income/business/f7203_stock_loss.ts | 5 ++++- .../forms/income/rental-passthrough/schedule_e.ts | 6 +++++- .../rental-passthrough/schedule_e_stock_loss.ts | 5 ++++- .../2025/return-processing/attachment-coverage.ts | 4 +++- 18 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_ty2026.test.ts diff --git a/forms/f1040/2025/domains/credits/earned-income/earned-income/eic-source.ts b/forms/f1040/2025/domains/credits/earned-income/earned-income/eic-source.ts index ffa84e68a..7e260d8ac 100644 --- a/forms/f1040/2025/domains/credits/earned-income/earned-income/eic-source.ts +++ b/forms/f1040/2025/domains/credits/earned-income/earned-income/eic-source.ts @@ -163,7 +163,7 @@ export function assertEicSource( ? [] : sCorpK1InputSchema.parse(pending.k1_s_corp).k1_s_corps; const passiveLossCopies = pending - ? projectPassiveSCorpLossCopies(pending) + ? projectPassiveSCorpLossCopies(pending, { taxYear, formType: "f1040" }) : undefined; assertK1EicReview(partnershipItems); const ownedLosses = sCorpItems.filter((item) => diff --git a/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_copies.test.ts b/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_copies.test.ts index c17d17e1d..023afd673 100644 --- a/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_copies.test.ts +++ b/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_copies.test.ts @@ -32,7 +32,10 @@ for ( passiveSCorpLossReturnInputs(c.cash, c.income), ); assertEquals(r.diagnostics, []); - const p = r.pending, projection = projectPassiveSCorpLossCopies(p)!; + const p = r.pending, projection = projectPassiveSCorpLossCopies(p, { + taxYear: 2025, + formType: "f1040", + })!; assertEquals(projection.allowed, c.allowed); const filer = extractFilerIdentity(p.f1040)!; const b = new XMLParser().parse( diff --git a/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_ty2026.test.ts b/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_ty2026.test.ts new file mode 100644 index 000000000..cb8b72efd --- /dev/null +++ b/forms/f1040/2025/domains/credits/earned-income/earned-income/eic_passive_s_corp_loss_ty2026.test.ts @@ -0,0 +1,15 @@ +import { test } from "bun:test"; +import { assertEquals } from "@std/assert"; +import { f1040_2026 } from "../../../../../2026/index.ts"; +import { passiveSCorpLossReturnInputs } from "./eic_passive_s_corp_loss.fixture.ts"; +import { projectPassiveSCorpLossCopies } from "../../../income/business/passive-s-corp-loss-copies.ts"; + +test("TY2026 passive S-corp copies replay Form 8582 and AGI under TY2026", () => { + const r = f1040_2026.executeReturn(passiveSCorpLossReturnInputs(1000, 500)); + assertEquals(r.diagnostics, []); + const projection = projectPassiveSCorpLossCopies(r.pending, { + taxYear: 2026, + formType: "f1040", + })!; + assertEquals(projection.allowed, 500); +}); diff --git a/forms/f1040/2025/domains/general/filing/form8886/k1-activity-source.ts b/forms/f1040/2025/domains/general/filing/form8886/k1-activity-source.ts index 8f64a7ac3..f1049450f 100644 --- a/forms/f1040/2025/domains/general/filing/form8886/k1-activity-source.ts +++ b/forms/f1040/2025/domains/general/filing/form8886/k1-activity-source.ts @@ -1,3 +1,4 @@ +import { mefTaxYear } from "../../../../mef/support/year-config.ts"; import { inputSchema as scheduleESchema } from "../../../../../nodes/inputs/income/rental-passthrough/schedule_e/index.ts"; import { z } from "zod"; import type { ExecuteResult } from "../../../../../../../core/runtime/executor.ts"; @@ -216,7 +217,10 @@ export function reconcileForm8886K1ActivityFacts( assertReturnScheduleJoins(pending.f1040, pending); const nativeRows = source.kind === ReturnSourceKind.TrustK1Activity ? [] - : scheduleEK1Part2Rows(nativePending).filter((row) => + : scheduleEK1Part2Rows(nativePending, { + taxYear: mefTaxYear(), + formType: "f1040", + }).filter((row) => row.ein === facts.ein && row.name === facts.name && row.code === (source.kind === ReturnSourceKind.PartnershipK1Activity ? "P" : "S") diff --git a/forms/f1040/2025/domains/income/business/form7203/form7203-owned-return.ts b/forms/f1040/2025/domains/income/business/form7203/form7203-owned-return.ts index 90c94f363..00806dd24 100644 --- a/forms/f1040/2025/domains/income/business/form7203/form7203-owned-return.ts +++ b/forms/f1040/2025/domains/income/business/form7203/form7203-owned-return.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { projectPassiveSCorpLossCopies } from "../passive-s-corp-loss-copies.ts"; import { isDeepStrictEqual } from "node:util"; @@ -5,6 +6,7 @@ import { isDeepStrictEqual } from "node:util"; * the owned shareholder's basis limitation and qualified-loss workpaper. */ export function assertOwned7203RequiredCopies( pending: Readonly>, + context: NodeContext, ): void { const records = pending.k1_s_corp as { k1_s_corps?: Record[]; @@ -18,7 +20,7 @@ export function assertOwned7203RequiredCopies( (pending.form8995 as Record | undefined) ?.current_passive_s_corp_loss !== undefined ) { - if (!projectPassiveSCorpLossCopies(pending)) { + if (!projectPassiveSCorpLossCopies(pending, context)) { throw Error( "Passive S-corporation loss requires its original K1, basis and qualified-loss copies", ); diff --git a/forms/f1040/2025/domains/income/business/passive-s-corp-loss-copies.ts b/forms/f1040/2025/domains/income/business/passive-s-corp-loss-copies.ts index aa8d26541..155ca73d2 100644 --- a/forms/f1040/2025/domains/income/business/passive-s-corp-loss-copies.ts +++ b/forms/f1040/2025/domains/income/business/passive-s-corp-loss-copies.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../core/types/node-context.ts"; import type { FilerIdentity } from "../../../../mef/header.ts"; import { isDeepStrictEqual } from "node:util"; import { inputSchema as sCorpSchema } from "../../../../nodes/inputs/income/rental-passthrough/k1_s_corp/index.ts"; @@ -25,6 +26,7 @@ const sum = (value: number | readonly number[] | undefined) => /** Component source replay. The separate whole-return export guard stays closed. */ export function projectPassiveSCorpLossCopies( pending: Readonly>, + context: NodeContext, ) { const items = pending.k1_s_corp === undefined ? [] @@ -116,10 +118,7 @@ export function projectPassiveSCorpLossCopies( "Passive S-corp actual Form8582 differs from its basis-limited source activity pool", ); } - const calculated = form8582.compute( - { taxYear: 2025, formType: "f1040" }, - pal, - ); + const calculated = form8582.compute(context, pal); const allowed = -Number( calculated.outputs.find((row) => row.nodeType === "schedule1")?.fields .line5_schedule_e ?? 0, @@ -139,7 +138,7 @@ export function projectPassiveSCorpLossCopies( ); } const expected = - agi_aggregator.compute({ taxYear: 2025, formType: "f1040" }, agi).outputs + agi_aggregator.compute(context, agi).outputs .find((row) => row.nodeType === "f1040")!.fields; for ( const key of ["line8_additional_income", "line10_adjustments", "line11_agi"] @@ -190,13 +189,14 @@ export function projectPassiveSCorp7203Copy( raw: Record, pending: Readonly> | undefined, filer: FilerIdentity | undefined, + context: NodeContext, ) { if (!pending || !isDeepStrictEqual(raw, pending.form7203)) { throw Error( "Passive Form7203 descriptor needs its exact actual basis source copy", ); } - const copies = projectPassiveSCorpLossCopies(pending); + const copies = projectPassiveSCorpLossCopies(pending, context); if (!copies) { throw Error("Passive Form7203 descriptor lacks its original K1 source"); } diff --git a/forms/f1040/2025/domains/income/rental-passthrough/schedule-e/schedule-e-k1-part2.ts b/forms/f1040/2025/domains/income/rental-passthrough/schedule-e/schedule-e-k1-part2.ts index 4acd126ce..1660c5208 100644 --- a/forms/f1040/2025/domains/income/rental-passthrough/schedule-e/schedule-e-k1-part2.ts +++ b/forms/f1040/2025/domains/income/rental-passthrough/schedule-e/schedule-e-k1-part2.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from "../../../../../../../core/types/node-context.ts"; import { projectPassiveSCorpLossCopies } from "../../business/passive-s-corp-loss-copies.ts"; import { inputSchema as partnershipSchema } from "../../../../../nodes/inputs/income/rental-passthrough/k1_partnership/index.ts"; import { inputSchema as sCorpSchema } from "../../../../../nodes/inputs/income/rental-passthrough/k1_s_corp/index.ts"; @@ -53,6 +54,7 @@ function classified( /** Source-backed positive Part II rows shared by native and PDF Schedule E. */ export function scheduleEK1Part2Rows( pending: Readonly> | undefined, + context: NodeContext, ): ScheduleEK1Part2Row[] { const general = pending?.general as Record | undefined; const digits = (value: unknown) => @@ -77,7 +79,7 @@ export function scheduleEK1Part2Rows( : sCorpSchema.parse(pending.k1_s_corp).k1_s_corps; const rows: ScheduleEK1Part2Row[] = []; const passiveLoss = pending - ? projectPassiveSCorpLossCopies(pending) + ? projectPassiveSCorpLossCopies(pending, context) : undefined; const ownerKeys: string[] = []; for (const item of partnerships) { diff --git a/forms/f1040/2025/mef/builder.ts b/forms/f1040/2025/mef/builder.ts index 396e59c65..27e8002b9 100644 --- a/forms/f1040/2025/mef/builder.ts +++ b/forms/f1040/2025/mef/builder.ts @@ -332,7 +332,7 @@ function buildReturnXml( assertScheduleBPreparedProjection(pending); assertTaxExemptInterestSource(pending); assertBusinessSchedule1Amounts(pending); - assertOwned7203RequiredCopies(pending); + assertOwned7203RequiredCopies(pending, { taxYear: year, formType: "f1040" }); assertHsaExcessRequiredCopy(pending); assertRequiredLtcSource({ pending, filer, taxYear: year }); assertLine1iCombatPayElectionSource(pending); @@ -400,7 +400,7 @@ function buildReturnXml( ) { throw new Error("Form 8949 needs its reconciled Schedule D"); } - assertAttachmentCoverage(pending, "mef", preparedForm8886); + assertAttachmentCoverage(pending, "mef", preparedForm8886, year); if ( pending.schedule_e?.schedule_es?.some((item) => item.property_type === 8 && diff --git a/forms/f1040/2025/mef/forms/deductions/business/f8995/f8995-route.ts b/forms/f1040/2025/mef/forms/deductions/business/f8995/f8995-route.ts index e266a9122..30dbc6a16 100644 --- a/forms/f1040/2025/mef/forms/deductions/business/f8995/f8995-route.ts +++ b/forms/f1040/2025/mef/forms/deductions/business/f8995/f8995-route.ts @@ -1420,7 +1420,10 @@ export function assertPositive8995( "Passive Form8995 descriptor needs its exact actual qualified-loss copy", ); } - const copies = projectPassiveSCorpLossCopies(pending); + const copies = projectPassiveSCorpLossCopies(pending, { + taxYear, + formType: "f1040", + }); if (!copies) { throw Error("Passive Form8995 descriptor lacks original K1/basis source"); } diff --git a/forms/f1040/2025/mef/forms/income/business/f7203_stock_loss.ts b/forms/f1040/2025/mef/forms/income/business/f7203_stock_loss.ts index 5e85254a9..495c5854f 100644 --- a/forms/f1040/2025/mef/forms/income/business/f7203_stock_loss.ts +++ b/forms/f1040/2025/mef/forms/income/business/f7203_stock_loss.ts @@ -222,6 +222,7 @@ export const form7203StockLoss: MefFormDescriptor< fields, context?.pending, context?.filer, + { taxYear: mefTaxYear(context), formType: "f1040" }, ); return buildFirstYearPassiveSCorp7203( p.source, diff --git a/forms/f1040/2025/mef/forms/income/business/f8582/f8582.ts b/forms/f1040/2025/mef/forms/income/business/f8582/f8582.ts index e49d36d03..9b16b6da4 100644 --- a/forms/f1040/2025/mef/forms/income/business/f8582/f8582.ts +++ b/forms/f1040/2025/mef/forms/income/business/f8582/f8582.ts @@ -88,7 +88,10 @@ function partVIIIRowKey( function linkedK1Activities(context: MefBuildContext) { const pending = context.pending; const passiveLoss = pending - ? projectPassiveSCorpLossCopies(pending) + ? projectPassiveSCorpLossCopies(pending, { + taxYear: mefTaxYear(context), + formType: "f1040", + }) : undefined; const partnerships = pending?.k1_partnership === undefined ? [] diff --git a/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e.ts b/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e.ts index ca407a67f..c08c15a8e 100644 --- a/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e.ts +++ b/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e.ts @@ -639,7 +639,10 @@ export const scheduleE: MefFormDescriptor<"schedule_e", Fields> = { { ...context.pending, schedule_e: fields }, ); } - const k1Rows = scheduleEK1Part2Rows(context?.pending); + const k1Rows = scheduleEK1Part2Rows(context?.pending, { + taxYear: mefTaxYear(context), + formType: "f1040", + }); if ((!fields || Object.keys(fields).length === 0) && k1Rows.length === 0) { return ""; } diff --git a/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e_stock_loss.ts b/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e_stock_loss.ts index 98b8aeb12..30689ef1f 100644 --- a/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e_stock_loss.ts +++ b/forms/f1040/2025/mef/forms/income/rental-passthrough/schedule_e_stock_loss.ts @@ -88,7 +88,10 @@ export const scheduleEStockLoss: MefFormDescriptor< pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040se--2025.pdf", build(fields, context) { if (fields.current_passive_s_corp_loss !== undefined) { - projectPassiveSCorp7203Copy(fields, context?.pending, context?.filer); + projectPassiveSCorp7203Copy(fields, context?.pending, context?.filer, { + taxYear: mefTaxYear(context), + formType: "f1040", + }); return ""; } return buildReviewedStockLossScheduleE(fields, context); diff --git a/forms/f1040/2025/pdf/builder.ts b/forms/f1040/2025/pdf/builder.ts index 6161639ce..e239544fa 100644 --- a/forms/f1040/2025/pdf/builder.ts +++ b/forms/f1040/2025/pdf/builder.ts @@ -645,7 +645,10 @@ async function buildPdfBytesInternal( assertScheduleBPreparedProjection(normalized); assertTaxExemptInterestSource(normalized); assertBusinessSchedule1Amounts(normalized); - assertOwned7203RequiredCopies(normalized); + assertOwned7203RequiredCopies(normalized, { + taxYear: mefTaxYear(), + formType: "f1040", + }); assertHsaExcessRequiredCopy(normalized); assertRequiredLtcSource({ pending: normalized, filer }); assertLine1iCombatPayElectionSource(normalized); @@ -686,7 +689,7 @@ async function buildPdfBytesInternal( assertSchedule1Form8621Source({ ...normalized, form8949: pending.form8949, - }); + }, { taxYear: mefTaxYear(), formType: "f1040" }); assertSchedule1NecSourceIdentity(normalized, filer); assertSchedule1KSourceIdentity(normalized, filer); assertScheduleFFarmSourceIdentity(normalized, filer); @@ -899,7 +902,12 @@ async function buildPdfBytesInternal( form8949Rows ?? [], ); } - assertAttachmentCoverage(normalized, "pdf", preparedBundle?.form8886Packets); + assertAttachmentCoverage( + normalized, + "pdf", + preparedBundle?.form8886Packets, + mefTaxYear(), + ); const merged = await PDFDocument.create({ updateMetadata: false }); const copyCounts = new Map(); diff --git a/forms/f1040/2025/pdf/forms/income/business/f7203_stock_loss.ts b/forms/f1040/2025/pdf/forms/income/business/f7203_stock_loss.ts index fc59d57cd..95792d0cc 100644 --- a/forms/f1040/2025/pdf/forms/income/business/f7203_stock_loss.ts +++ b/forms/f1040/2025/pdf/forms/income/business/f7203_stock_loss.ts @@ -172,7 +172,10 @@ export const form7203StockLossPdf: PdfFormDescriptor = { instances(raw, filer, allPending) { if (Object.keys(raw).length === 0) return []; if (raw.current_passive_s_corp_loss !== undefined) { - return [projectPassiveSCorp7203Copy(raw, allPending, filer).pdfFields]; + return [projectPassiveSCorp7203Copy(raw, allPending, filer, { + taxYear: mefTaxYear(), + formType: "f1040", + }).pdfFields]; } if ( Array.isArray(raw.owned_debt_loss_sources) && 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 a7839a683..16a7c5d51 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,3 +1,4 @@ +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 { @@ -259,7 +260,10 @@ export const scheduleEPdf: PdfFormDescriptor = { }, ], projectFields(raw, allPending) { - const k1Rows = scheduleEK1Part2Rows(allPending); + const k1Rows = scheduleEK1Part2Rows(allPending, { + taxYear: mefTaxYear(), + formType: "f1040", + }); if (Object.keys(raw).length === 0 && k1Rows.length === 0) return {}; const input = inputSchema.parse(raw); const trustRows = input.estate_trust_rows ?? []; diff --git a/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e_stock_loss.ts b/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e_stock_loss.ts index 6494235b4..978070947 100644 --- a/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e_stock_loss.ts +++ b/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e_stock_loss.ts @@ -89,7 +89,10 @@ export const scheduleEStockLossPdf: PdfFormDescriptor = { instances(raw, filer, allPending) { if (Object.keys(raw).length === 0) return []; if (raw.current_passive_s_corp_loss !== undefined) { - projectPassiveSCorp7203Copy(raw, allPending, filer); + projectPassiveSCorp7203Copy(raw, allPending, filer, { + taxYear: mefTaxYear(), + formType: "f1040", + }); return []; } const pending = allPending ?? {}; diff --git a/forms/f1040/2025/return-processing/attachment-coverage.ts b/forms/f1040/2025/return-processing/attachment-coverage.ts index 55ef196de..15775797f 100644 --- a/forms/f1040/2025/return-processing/attachment-coverage.ts +++ b/forms/f1040/2025/return-processing/attachment-coverage.ts @@ -1,3 +1,4 @@ +import { mefTaxYear } from "../mef/support/year-config.ts"; import { hasUnstatusedForm8874Investment } from "../domains/credits/business/form8874/form8874_cde_status_evidence.ts"; import { form8886CoversLargeCapitalSources, @@ -488,6 +489,7 @@ export function assertAttachmentCoverage( pending: object, exportKind: ExportKind, preparedForm8886?: PreparedForm8886ReturnPackets, + taxYear: number = mefTaxYear(), ): void { const byKey = pending as Readonly>; if (byKey.benefit_1042s !== undefined) { @@ -610,7 +612,7 @@ export function assertAttachmentCoverage( const passiveLossCopies = (byKey.form7203 as Record | undefined) ?.current_passive_s_corp_loss !== undefined - ? projectPassiveSCorpLossCopies(byKey) + ? projectPassiveSCorpLossCopies(byKey, { taxYear, formType: "f1040" }) : undefined; for (const form of MISSING_ATTACHMENTS) { if (!form.exportKinds.includes(exportKind)) continue; From 91073f86f41bc00dec126fde9a7b820fe2c54549 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:55:14 +0000 Subject: [PATCH 12/26] chore: shrink year-pinned baseline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- scripts/testing/year-pinned-baseline.json | 100 ---------------------- 1 file changed, 100 deletions(-) diff --git a/scripts/testing/year-pinned-baseline.json b/scripts/testing/year-pinned-baseline.json index 887066bed..e4d98390c 100644 --- a/scripts/testing/year-pinned-baseline.json +++ b/scripts/testing/year-pinned-baseline.json @@ -229,11 +229,6 @@ "pattern": "year-identifier", "count": 5 }, - { - "path": "forms/f1040/2025/domains/deductions/itemized/schedule-a/schedule_a_line16_estate_source.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/2025/domains/general/filing/form8886/document.ts", "pattern": "date-pinned", @@ -309,11 +304,6 @@ "pattern": "year-identifier", "count": 4 }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_amt_projected_return.ts", - "pattern": "year-comparison", - "count": 1 - }, { "path": "forms/f1040/2025/domains/income/business/form172/form172_carry_absorption.ts", "pattern": "year-comparison", @@ -334,19 +324,9 @@ "pattern": "z-literal", "count": 1 }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts", - "pattern": "year-comparison", - "count": 2 - }, { "path": "forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts", "pattern": "year-identifier", - "count": 2 - }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_current_deduction.ts", - "pattern": "year-value", "count": 1 }, { @@ -369,61 +349,16 @@ "pattern": "z-literal", "count": 1 }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts", - "pattern": "year-comparison", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_modified_income.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_projected_return.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.ts", "pattern": "year-identifier", "count": 2 }, - { - "path": "forms/f1040/2025/domains/income/business/form172/form172_reviewed_return.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/income/business/passive-s-corp-loss-copies.ts", - "pattern": "year-value", - "count": 2 - }, - { - "path": "forms/f1040/2025/domains/income/foreign/form8621/form8621_1294_refigure.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/income/foreign/schedule1-form8621-source.ts", - "pattern": "year-value", - "count": 1 - }, { "path": "forms/f1040/2025/domains/income/investments/schedule-b/schedule-b-interest-reconciliation.ts", "pattern": "year-value", "count": 1 }, - { - "path": "forms/f1040/2025/domains/income/other/form982/form982-qpri.ts", - "pattern": "config-index", - "count": 2 - }, - { - "path": "forms/f1040/2025/domains/income/other/form982/form982-qpri.ts", - "pattern": "year-value", - "count": 2 - }, { "path": "forms/f1040/2025/domains/income/rental-passthrough/k1_trust/trust-k1-native-copy-review.ts", "pattern": "year-identifier", @@ -494,41 +429,6 @@ "pattern": "z-literal", "count": 1 }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_amt_reconciliation.ts", - "pattern": "year-identifier", - "count": 7 - }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_collection_reconciliation.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_estate_part2_reconciliation.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_full_share_reconciliation.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_1099r_reconciliation.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_multiple_recipient_reconciliation.ts", - "pattern": "year-value", - "count": 1 - }, - { - "path": "forms/f1040/2025/domains/taxes/retirement/form4972/form4972_nua_reconciliation.ts", - "pattern": "year-value", - "count": 3 - }, { "path": "forms/f1040/2025/domains/taxes/underpayment/form2210/form2210_box_e_finalized.ts", "pattern": "year-identifier", From 154d9b19fb4050d7e4c71a5345ef2680dbc1223f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:58:00 +0000 Subject: [PATCH 13/26] test(f1040): type the TY2026 participant collection check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../taxes/retirement/form4972/form4972_ty2026_context.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_ty2026_context.test.ts b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_ty2026_context.test.ts index 2ac547e5a..fa085279d 100644 --- a/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_ty2026_context.test.ts +++ b/forms/f1040/2025/domains/taxes/retirement/form4972/form4972_ty2026_context.test.ts @@ -190,5 +190,5 @@ test("TY2026 participant collection recomputes each participant under TY2026", ( extractFilerIdentity(pending.f1040)!, ty2026, ); - assertEquals(scoped.length, pending.form4972.forms.length); + assertEquals(scoped.length > 0 && scoped.every((entry) => entry.tax > 0), true); }); From 0de56ae08ecbe79896d7c6d6f1748ee980d0270c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:54:24 +0000 Subject: [PATCH 14/26] refactor(pdf): PDF builder and Form 1040/Schedule 3 projections take the return year Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- cli/commands/export.ts | 3 ++ cli/main.ts | 1 + core/types/form-definition.ts | 2 + forms/f1040/2025/pdf/builder.ts | 48 +++++++++++++------ .../forms/general/return-assembly/f1040.ts | 32 ++++++++----- .../general/return-assembly/schedule3.ts | 12 +++-- 6 files changed, 67 insertions(+), 31 deletions(-) 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/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()); From 22ccb520868cec462fc0f12002a745bb09f4c63d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:54:24 +0000 Subject: [PATCH 15/26] feat(ty2026): PDF export of Form 1040 and Schedules 1-3 on IRS 2026 drafts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- docs/architecture/STRUCTURE.md | 3 +- forms/f1040/2026/README.md | 11 +- forms/f1040/2026/index.test.ts | 6 +- forms/f1040/2026/index.ts | 15 +- forms/f1040/2026/pdf/builder.test.ts | 122 +++++++++ forms/f1040/2026/pdf/builder.ts | 38 +++ forms/f1040/2026/pdf/draft-template.ts | 82 ++++++ .../forms/general/return-assembly/f1040.ts | 250 ++++++++++++++++++ .../general/return-assembly/schedule1.ts | 17 ++ .../general/return-assembly/schedule3.ts | 34 +++ .../2026/pdf/forms/taxes/other/schedule2.ts | 222 ++++++++++++++++ 11 files changed, 790 insertions(+), 10 deletions(-) create mode 100644 forms/f1040/2026/pdf/builder.test.ts create mode 100644 forms/f1040/2026/pdf/builder.ts create mode 100644 forms/f1040/2026/pdf/draft-template.ts create mode 100644 forms/f1040/2026/pdf/forms/general/return-assembly/f1040.ts create mode 100644 forms/f1040/2026/pdf/forms/general/return-assembly/schedule1.ts create mode 100644 forms/f1040/2026/pdf/forms/general/return-assembly/schedule3.ts create mode 100644 forms/f1040/2026/pdf/forms/taxes/other/schedule2.ts 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/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..7b1eabf41 --- /dev/null +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -0,0 +1,122 @@ +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_FORMS, TY2026_PDF_MAPPED_FORMS } 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 () => { + for (const key of TY2026_PDF_MAPPED_FORMS) { + const descriptor = TY2026_PDF_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..cca3bdd38 --- /dev/null +++ b/forms/f1040/2026/pdf/builder.ts @@ -0,0 +1,38 @@ +import type { FilerIdentity } from "../../mef/header.ts"; +import type { MefBundle } from "../../2025/mef/builder.ts"; +import { buildPdfBytes, type PdfPageOrigin } from "../../2025/pdf/builder.ts"; +import { ALL_PDF_FORMS } from "../../2025/pdf/forms/index.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 const TY2026_PDF_FORMS: ReadonlyArray = ALL_PDF_FORMS + .map((descriptor) => + MAPPED.get(descriptor.pendingKey) ?? refusedOnTy2026(descriptor) + ); + +export const TY2026_PDF_MAPPED_FORMS: readonly string[] = [...MAPPED.keys()]; + +export function buildPdfBytes2026( + pending: Record, + filer: FilerIdentity | undefined, + cacheDir = ".pdf-cache", + preparedBundle?: MefBundle, + pageOrigins?: PdfPageOrigin[], +): Promise { + return buildPdfBytes(pending, filer, cacheDir, preparedBundle, pageOrigins, { + descriptors: TY2026_PDF_FORMS, + 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); + }, +}; From f3014109e5c17b91aafea5f0cabee33f97111ed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:22:58 +0000 Subject: [PATCH 16/26] fix(ty2026): load the 2025 PDF modules on use so the compiled binary starts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- forms/f1040/2026/pdf/builder.test.ts | 5 +++-- forms/f1040/2026/pdf/builder.ts | 33 +++++++++++++++++++--------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index 7b1eabf41..2beea9937 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -9,7 +9,7 @@ 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_FORMS, TY2026_PDF_MAPPED_FORMS } from "./builder.ts"; +import { TY2026_PDF_MAPPED_FORMS, ty2026PdfForms } from "./builder.ts"; import { loadPdfTemplate } from "../../2025/pdf/support/template-cache.ts"; const fixture = (id: string) => { @@ -100,8 +100,9 @@ test("TY2026 PDF refuses, by name, a form with no TY2026 mapping", async () => { }); 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 = TY2026_PDF_FORMS.find((row) => row.pendingKey === key)!; + const descriptor = forms.find((row) => row.pendingKey === key)!; const doc = await PDFDocument.load( await loadPdfTemplate( descriptor.pdfUrl, diff --git a/forms/f1040/2026/pdf/builder.ts b/forms/f1040/2026/pdf/builder.ts index cca3bdd38..b9e945963 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -1,7 +1,6 @@ import type { FilerIdentity } from "../../mef/header.ts"; import type { MefBundle } from "../../2025/mef/builder.ts"; -import { buildPdfBytes, type PdfPageOrigin } from "../../2025/pdf/builder.ts"; -import { ALL_PDF_FORMS } from "../../2025/pdf/forms/index.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"; @@ -16,23 +15,37 @@ const MAPPED: ReadonlyMap = new Map( ); /** The 2025 print order; forms without a TY2026 mapping refuse when needed. */ -export const TY2026_PDF_FORMS: ReadonlyArray = ALL_PDF_FORMS - .map((descriptor) => +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 function buildPdfBytes2026( +export async function buildPdfBytes2026( pending: Record, filer: FilerIdentity | undefined, cacheDir = ".pdf-cache", preparedBundle?: MefBundle, pageOrigins?: PdfPageOrigin[], ): Promise { - return buildPdfBytes(pending, filer, cacheDir, preparedBundle, pageOrigins, { - descriptors: TY2026_PDF_FORMS, - taxYear: 2026, - subject: TY2026_DRAFT_NOTICE, - }); + 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, + }, + ); } From dc0f1ecbea360fc8f78d4e807f48c0b05b5dd9c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:22:58 +0000 Subject: [PATCH 17/26] test(release): smoke a TY2026 draft PDF export from the binary Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- scripts/release/smoke-release-binary.ts | 45 ++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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 }); From 990845a338a64c5bf5a7748ac8128483ec521642 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:45:44 +0000 Subject: [PATCH 18/26] feat(ty2026): PDF export of Schedules B, C, D, SE and Forms 8863, 8889, 8995 on IRS 2026 drafts The 2025 projections behind them now take the tax year, and the PDF builder's source checks use the return's year instead of 2025. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- forms/f1040/2025/pdf/builder.ts | 35 +++--- .../pdf/forms/adjustments/health/f8889.ts | 23 ++-- .../pdf/forms/credits/individual/f8863.ts | 13 +- .../forms/deductions/business/f8995/f8995.ts | 13 +- .../pdf/forms/income/business/schedule_c.ts | 17 +-- .../taxes/self-employment/schedule_se.ts | 15 ++- forms/f1040/2026/pdf/builder.test.ts | 115 ++++++++++++++++++ forms/f1040/2026/pdf/builder.ts | 23 +++- forms/f1040/2026/pdf/draft-template.ts | 44 ++++++- .../pdf/forms/adjustments/health/f8889.ts | 16 +++ .../pdf/forms/credits/individual/f8863.ts | 18 +++ .../pdf/forms/deductions/business/f8995.ts | 72 +++++++++++ .../pdf/forms/income/business/schedule_c.ts | 114 +++++++++++++++++ .../forms/income/investments/schedule_b.ts | 19 +++ .../forms/income/investments/schedule_d.ts | 16 +++ .../taxes/self-employment/schedule_se.ts | 19 +++ 16 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 forms/f1040/2026/pdf/forms/adjustments/health/f8889.ts create mode 100644 forms/f1040/2026/pdf/forms/credits/individual/f8863.ts create mode 100644 forms/f1040/2026/pdf/forms/deductions/business/f8995.ts create mode 100644 forms/f1040/2026/pdf/forms/income/business/schedule_c.ts create mode 100644 forms/f1040/2026/pdf/forms/income/investments/schedule_b.ts create mode 100644 forms/f1040/2026/pdf/forms/income/investments/schedule_d.ts create mode 100644 forms/f1040/2026/pdf/forms/taxes/self-employment/schedule_se.ts diff --git a/forms/f1040/2025/pdf/builder.ts b/forms/f1040/2025/pdf/builder.ts index 4a16e8b53..216e25a5d 100644 --- a/forms/f1040/2025/pdf/builder.ts +++ b/forms/f1040/2025/pdf/builder.ts @@ -613,19 +613,24 @@ async function buildPdfBytesInternal( assertSingleFarmQbiReturn(normalized, options.taxYear); if (normalized.f1040) { assertF1040FinalHeader(normalized.f1040, filer); - assertLine1hSupportedSource(normalized.f1040, 2025, normalized, filer); + assertLine1hSupportedSource( + normalized.f1040, + options.taxYear, + normalized, + filer, + ); } assertGeneral1040HeaderSource(normalized); - assertGeneral1040DependentSource(normalized, 2025); + assertGeneral1040DependentSource(normalized, options.taxYear); assertGeneral1040DepositSource(normalized, filer); if (filer) assert1098EInterestSource(normalized, filer); assertExtensionPaymentSource(normalized, filer); - assert1099RRecipientOwner(normalized.f1099r, filer, 2025); - assertPositiveW2GRecipient(normalized.w2g, filer, 2025); + assert1099RRecipientOwner(normalized.f1099r, filer, options.taxYear); + assertPositiveW2GRecipient(normalized.w2g, filer, options.taxYear); assertNecWithholdingRecipient(normalized.f1099nec, filer); assert1099BRecipientOwner(normalized.f1099b, filer, options.taxYear); assertNoRepeatedBrokerSaleSources(normalized.f1099b, normalized.f8949); - assertCapitalSaleSourceRows(normalized, 2025); + assertCapitalSaleSourceRows(normalized, options.taxYear); assertPatrIssuedCopies(normalized.f1099patr); assertPatrWithholdingRecipient(normalized.f1099patr, filer); assertLine1bHouseholdWageSource(normalized); @@ -669,13 +674,13 @@ async function buildPdfBytesInternal( assertSchedule2W2Line13Sources(normalized); assertSchedule2W2Line17KSource(normalized); assertSchedule2Line17HSources(normalized, filer); - assertSchedule2Form4137Tax(normalized, 2025); - assertLine1cForm4137Income(normalized, 2025); - assertSchedule2Form8919Tax(normalized, 2025); - assertLine1gForm8919Wages(normalized, 2025); - assertSchedule2ScheduleHTax(normalized, 2025); + assertSchedule2Form4137Tax(normalized, options.taxYear); + assertLine1cForm4137Income(normalized, options.taxYear); + assertSchedule2Form8919Tax(normalized, options.taxYear); + assertLine1gForm8919Wages(normalized, options.taxYear); + assertSchedule2ScheduleHTax(normalized, options.taxYear); assertSchedule2Form8960Tax(normalized); - assertSchedule2ScheduleSETax(normalized, 2025); + assertSchedule2ScheduleSETax(normalized, options.taxYear); assertSchedule2Form8828Tax(normalized); assertSchedule2Form8936Repayment(normalized); assertSchedule3Form8859Credit(normalized); @@ -686,17 +691,17 @@ async function buildPdfBytesInternal( assertF8288WithholdingOwner(normalized.f8288, filer); assertSocialSecurityBenefitSource(normalized); assertBenefitStatementOwner(normalized, filer); - assertRrb1099rPensionSource(normalized, filer, 2025); - assertIra1099rIncomeSource(normalized, 2025); + assertRrb1099rPensionSource(normalized, filer, options.taxYear); + assertIra1099rIncomeSource(normalized, options.taxYear); assertForm8915FSourceLinks(normalized); assertKIncomeClassification(normalized); if (filer) { - assertEitcChildSources(pending, filer, 2025); + assertEitcChildSources(pending, filer, options.taxYear); assertKReportedErrorSources(normalized, filer); assertScheduleCReceiptSourceIdentity(normalized, filer); assertScheduleCStatutoryW2Sources(normalized, filer); assertKWithholdingSourceIdentity(normalized, filer); - assertKPersonalSaleSources(pending, filer, 2025); + assertKPersonalSaleSources(pending, filer, options.taxYear); assertSchedule1Box3SourceIdentity(normalized, filer); assertSchedule1Box8SourceIdentity(normalized, filer); assertSchedule1Form8814Source(normalized); diff --git a/forms/f1040/2025/pdf/forms/adjustments/health/f8889.ts b/forms/f1040/2025/pdf/forms/adjustments/health/f8889.ts index 8bc42c6f9..f38ebe554 100644 --- a/forms/f1040/2025/pdf/forms/adjustments/health/f8889.ts +++ b/forms/f1040/2025/pdf/forms/adjustments/health/f8889.ts @@ -1,4 +1,3 @@ -import { mefTaxYear } from "../../../../mef/support/year-config.ts"; import { z } from "zod"; import type { PdfFieldEntry, PdfFormDescriptor } from "../../../review-support/form-descriptor.ts"; import { @@ -172,7 +171,8 @@ const fields: ReadonlyArray = [ }, ]; -export const form8889Pdf: PdfFormDescriptor = { +export function form8889PdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "form8889", pdfUrl: "https://www.irs.gov/pub/irs-prior/f8889--2025.pdf", instances(pending, filer, allPending) { @@ -213,13 +213,13 @@ export const form8889Pdf: PdfFormDescriptor = { ); } } - reconcileSpouseOnlyForm8889(forms, allPending, filer, mefTaxYear()); - reconcileDatedExceptionForm8889(forms, allPending, filer, mefTaxYear()); - reconcileCode2Form8889(forms, allPending, filer, mefTaxYear()); - reconcilePrimaryLastMonthRuleForm8889(forms, allPending, filer, mefTaxYear()); - reconcileRolloverForm8889(forms, allPending, filer, mefTaxYear()); - reconcilePairedForm8889(forms, allPending, filer, mefTaxYear()); - reconcilePrimaryMixedMedicalForm8889(forms, allPending, filer, mefTaxYear()); + reconcileSpouseOnlyForm8889(forms, allPending, filer, taxYear); + reconcileDatedExceptionForm8889(forms, allPending, filer, taxYear); + reconcileCode2Form8889(forms, allPending, filer, taxYear); + reconcilePrimaryLastMonthRuleForm8889(forms, allPending, filer, taxYear); + reconcileRolloverForm8889(forms, allPending, filer, taxYear); + reconcilePairedForm8889(forms, allPending, filer, taxYear); + reconcilePrimaryMixedMedicalForm8889(forms, allPending, filer, taxYear); const numericKeys = new Set(fields.filter((entry) => entry.domainKey.startsWith("print_line") && entry.domainKey !== "print_line1_coverage" && @@ -260,4 +260,7 @@ export const form8889Pdf: PdfFormDescriptor = { return forms; }, fields, -}; + }; +} + +export const form8889Pdf: PdfFormDescriptor = form8889PdfFor(2025); diff --git a/forms/f1040/2025/pdf/forms/credits/individual/f8863.ts b/forms/f1040/2025/pdf/forms/credits/individual/f8863.ts index c45b3c8b4..8611da59f 100644 --- a/forms/f1040/2025/pdf/forms/credits/individual/f8863.ts +++ b/forms/f1040/2025/pdf/forms/credits/individual/f8863.ts @@ -1,4 +1,3 @@ -import { mefTaxYear } from "../../../../mef/support/year-config.ts"; import type { PdfFieldEntry, PdfFormDescriptor, @@ -224,7 +223,8 @@ function institutionFields(item: F8863Item): Record { return projected; } -export const form8863Pdf: PdfFormDescriptor = { +export function form8863PdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "f8863", pdfUrl: "https://www.irs.gov/pub/irs-prior/f8863--2025.pdf", fields, @@ -239,10 +239,10 @@ export const form8863Pdf: PdfFormDescriptor = { instances(raw, filer, allPending) { if (Object.keys(raw).length === 0) return []; const source = inputSchema.parse(raw); - if (!nativeForm8863.build(source, { filer, pending: allPending })) { + if (!nativeForm8863.build(source, { filer, pending: allPending, taxYear })) { return []; } - const lines = calculateForm8863Lines(source, mefTaxYear()); + const lines = calculateForm8863Lines(source, taxYear); if (!lines) return []; if (!filer) throw new Error("Form 8863 PDF needs filer identity"); const [taxpayerA, taxpayerB, taxpayerC] = ssnParts(filer.primarySSN); @@ -357,4 +357,7 @@ export const form8863Pdf: PdfFormDescriptor = { ]; }); }, -}; + }; +} + +export const form8863Pdf: PdfFormDescriptor = form8863PdfFor(2025); diff --git a/forms/f1040/2025/pdf/forms/deductions/business/f8995/f8995.ts b/forms/f1040/2025/pdf/forms/deductions/business/f8995/f8995.ts index f74fcd3d6..72cec4432 100644 --- a/forms/f1040/2025/pdf/forms/deductions/business/f8995/f8995.ts +++ b/forms/f1040/2025/pdf/forms/deductions/business/f8995/f8995.ts @@ -4,7 +4,6 @@ import { assertNoUnfiled8995Loss, assertPositive8995, } from "../../../../../mef/forms/deductions/business/f8995/f8995-route.ts"; -import { mefTaxYear } from "../../../../../mef/support/year-config.ts"; const page1 = "topmostSubform[0].Page1[0]."; const fields: ReadonlyArray = [ @@ -67,7 +66,8 @@ const fields: ReadonlyArray = [ }), ]; -export const form8995Pdf: PdfFormDescriptor = { +export function form8995PdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "form8995", pdfUrl: "https://www.irs.gov/pub/irs-prior/f8995--2025.pdf", filerFields: [ @@ -110,7 +110,7 @@ export const form8995Pdf: PdfFormDescriptor = { !(typeof fields.line1_qbi === "number" && fields.line1_qbi > 0 && typeof fields.line1_business_reference === "string") ) { - assertNoUnfiled8995Loss(fields, allPending, mefTaxYear()); + assertNoUnfiled8995Loss(fields, allPending, taxYear); return {}; } if ( @@ -122,7 +122,7 @@ export const form8995Pdf: PdfFormDescriptor = { const { businesses, lines } = assertPositive8995( fields, allPending, - 2025, + taxYear, ); return { pdf_current_joint_ordinary: allPending.general?.filing_status === "mfj" && @@ -153,4 +153,7 @@ export const form8995Pdf: PdfFormDescriptor = { ), }; }, -}; + }; +} + +export const form8995Pdf: PdfFormDescriptor = form8995PdfFor(2025); diff --git a/forms/f1040/2025/pdf/forms/income/business/schedule_c.ts b/forms/f1040/2025/pdf/forms/income/business/schedule_c.ts index 06cbf7440..caf1c1f2d 100644 --- a/forms/f1040/2025/pdf/forms/income/business/schedule_c.ts +++ b/forms/f1040/2025/pdf/forms/income/business/schedule_c.ts @@ -30,7 +30,6 @@ import { calculateForm5884, inputSchema as form5884InputSchema, } from "../../../../../nodes/inputs/credits/business/f5884/index.ts"; -import { mefTaxYear } from "../../../../mef/support/year-config.ts"; // Field names and locations are from the two-page 2025 Schedule C AcroForm. const page1 = "topmostSubform[0].Page1[0]."; @@ -398,7 +397,8 @@ function proprietorIdentity( return { proprietor_name: name, proprietor_ssn: ssn.replaceAll("-", "") }; } -export const scheduleCPdf: PdfFormDescriptor = { +export function scheduleCPdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "schedule_c", pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040sc--2025.pdf", fields, @@ -408,7 +408,7 @@ export const scheduleCPdf: PdfFormDescriptor = { assertFarmWotcReturn( allPending.form8995a ?? allPending.form8995, allPending, - mefTaxYear(), + taxYear, undefined, { key: "schedule_c", value: raw }, ); @@ -427,7 +427,7 @@ export const scheduleCPdf: PdfFormDescriptor = { assertCurrentYearSection481aMatches( allPending.f3115, input.section481a_adjustments ?? [], - 2025, + taxYear, ); } if (input.schedule_cs.length === 0) { @@ -472,7 +472,7 @@ export const scheduleCPdf: PdfFormDescriptor = { items.forEach((item) => assertScheduleCInterestExempt( item, - CONFIG_BY_YEAR[2025].smallBizGrossReceipts, + CONFIG_BY_YEAR[taxYear].smallBizGrossReceipts, ) ); const wotc = wotcReductionsByBusiness(input); @@ -601,7 +601,7 @@ export const scheduleCPdf: PdfFormDescriptor = { ? ` Reference: ${String(fields.business_reference)}` : ""); await appendExpenseStatement(document, { - title: "Schedule C (2025) - Part V other expenses", + title: `Schedule C (${taxYear}) - Part V other expenses`, proprietorName: String(fields.proprietor_name ?? ""), proprietorSsn: String(fields.proprietor_ssn ?? ""), activityLabel: businessLabel, @@ -610,4 +610,7 @@ export const scheduleCPdf: PdfFormDescriptor = { expectedTotal: Number(fields.part_v_statement_total), }); }, -}; + }; +} + +export const scheduleCPdf: PdfFormDescriptor = scheduleCPdfFor(2025); diff --git a/forms/f1040/2025/pdf/forms/taxes/self-employment/schedule_se.ts b/forms/f1040/2025/pdf/forms/taxes/self-employment/schedule_se.ts index 3a03b2c37..8fbcf8c18 100644 --- a/forms/f1040/2025/pdf/forms/taxes/self-employment/schedule_se.ts +++ b/forms/f1040/2025/pdf/forms/taxes/self-employment/schedule_se.ts @@ -4,7 +4,6 @@ import { scheduleSELines } from "../../../../../nodes/intermediate/forms/taxes/s import { CONFIG_BY_YEAR } from "../../../../../nodes/config/index.ts"; import { FilingStatus } from "../../../../../nodes/types.ts"; import { scheduleSeSpouseProprietor } from "../../../../domains/taxes/self-employment/schedule-se/schedule-se-proprietor.ts"; -import { mefTaxYear } from "../../../../mef/support/year-config.ts"; // IRS Schedule SE (2025) AcroForm field names. // Verified layout from https://www.irs.gov/pub/irs-prior/f1040sse--2025.pdf @@ -63,17 +62,18 @@ const fields: ReadonlyArray = [ { kind: "text", domainKey: "line15", pdfField: `${page2}f2_2[0]` }, ]; -export const scheduleSePdf: PdfFormDescriptor = { +export function scheduleSePdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "schedule_se", pdfUrl: "https://www.irs.gov/pub/irs-prior/f1040sse--2025.pdf", fields, projectFields(fields, allPending) { if (Object.keys(fields).length === 0) return fields; if (fields.owner_identity) { - assertOwnedScheduleSE(allPending, mefTaxYear()); + assertOwnedScheduleSE(allPending, taxYear); return fields; } - const lines = scheduleSELines(fields, CONFIG_BY_YEAR[2025].ssWageBase); + const lines = scheduleSELines(fields, CONFIG_BY_YEAR[taxYear].ssWageBase); const spouseOwned = scheduleSeSpouseProprietor(allPending, fields); const general = allPending?.general; const return1040 = allPending?.f1040; @@ -111,7 +111,7 @@ export const scheduleSePdf: PdfFormDescriptor = { }, instances(fields, filer, allPending) { const owned = allPending - ? assertOwnedScheduleSE(allPending, mefTaxYear(), filer) + ? assertOwnedScheduleSE(allPending, taxYear, filer) : undefined; if (!owned) return [fields]; return owned.instances.map((row) => { @@ -135,4 +135,7 @@ export const scheduleSePdf: PdfFormDescriptor = { // (Schedule 2 line 4); W-2 social security wages alone do not require it. includeWhen: (_fields, all) => (((all?.["schedule2"]?.["line4_se_tax"]) as number | undefined) ?? 0) > 0, -}; + }; +} + +export const scheduleSePdf: PdfFormDescriptor = scheduleSePdfFor(2025); diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index 2beea9937..8821022a7 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -91,6 +91,121 @@ test("TY2026 PDF moves W-2 uncollected tax to Schedule 2 line 17c", async () => assertMatch(printed, /23 Additional taxes[^\n]* 23 300/); }); +const soleProprietor = { + general: { + filing_status: "single", + taxpayer_first_name: "Alex", + taxpayer_last_name: "Example", + taxpayer_ssn: "111-22-3333", + taxpayer_dob: "1985-06-15", + address_line1: "1 Example Way", + address_city: "Austin", + address_state: "TX", + address_zip: "78701", + digital_assets: false, + taxpayer_can_be_claimed_as_dependent: false, + qbi_no_prior_loss_or_suspended_loss_confirmed: true, + qbi_not_patron_of_specified_cooperative_confirmed: true, + }, + w2: [{ + box1_wages: 60000, + box2_fed_withheld: 9000, + employee_ssn: "111-22-3333", + box3_ss_wages: 60000, + box4_ss_withheld: 3720, + box5_medicare_wages: 60000, + box6_medicare_withheld: 870, + employer_ein: "12-3456789", + employer_name: "Example Employer", + employer_address_line1: "10 Employer Road", + employer_address_city: "Austin", + employer_address_state: "TX", + employer_address_zip: "78701", + box12_entries: [], + }], + schedule_c: [{ + business_reference: "synthetic-design-studio", + proprietor_recipient: "T", + line_a_principal_business: "Graphic design", + line_b_business_code: "541430", + line_c_business_name: "Example Design Studio", + line_e_business_address: { + line1: "20 Studio Lane", + line2: "Suite 4", + city: "Austin", + state: "TX", + zip: "78702", + }, + line_f_accounting_method: "cash", + line_g_material_participation: true, + line_i_made_1099_payments: false, + qbi_no_other_adjustments_confirmed: true, + line_1_gross_receipts: 30000, + line_8_advertising: 1200, + line_18_office_expense: 700, + line_22_supplies: 450, + }], +}; + +test("TY2026 PDF prints Schedules C and SE and Form 8995 on the 2026 drafts", async () => { + const printed = await text(await render(soleProprietor)); + assertMatch(printed, /SCHEDULE C[\s\S]*2026/); + assertMatch(printed, /\n 20 Studio Lane Suite 4\n[^\n]*\n Austin TX 78702\n/); + assertMatch(printed, /check the box if this income[\s\S]*? 1 30000/); + assertMatch(printed, /8 Advertising[^\n]* 8 1200 18 Office expense[^\n]* 18 700/); + assertMatch(printed, /22 Supplies[^\n]* 22 450/); + assertMatch(printed, /28 Total expenses before[^\n]* 28 2350/); + assertMatch(printed, /29 Tentative profit[^\n]* 29 27650/); + assertMatch(printed, /SCHEDULE SE[\s\S]*2026/); + assertMatch(printed, /code A \(other than\n[^\n]* 2 27650/); + assertMatch(printed, /4a If line 3 is more than zero[^\n]* 4a 25535/); + assertMatch(printed, /and go to line 11[^\n]* 8a 60000/); + assertMatch(printed, /Form 8995[\s\S]*2026/); + assertMatch(printed, /2 Total qualified business income[^\n]* 2 25696/); + assertMatch(printed, /15 Qualified business income deduction before[\s\S]*? 15 5139/); + assertMatch(printed, /16 Minimum deduction[^\n]* 16 0/); + assertMatch(printed, /17 Qualified business income deduction\. Enter[\s\S]*? 17 5139/); + assertMatch(printed, /18 Total qualified business \(loss\)[^\n]* 18 \( 0\)/); +}); + +test("TY2026 PDF prints Schedule B on the 2026 draft", async () => { + const { schedule_c: _, ...wageEarner } = soleProprietor; + const printed = await text( + await render({ + ...wageEarner, + schedule_b_part_iii: { + foreign_accounts_question: false, + foreign_trust_question: false, + }, + f1099int: [ + { payer_name: "First Example Bank", recipient_tin: "111223333", box1: 1200 }, + { payer_name: "Second Example Bank", recipient_tin: "111223333", box1: 800 }, + ], + }), + ); + assertMatch(printed, /SCHEDULE B[\s\S]*2026/); + assertMatch(printed, /First Example Bank[^\n]* 1200/); + assertMatch(printed, /Second Example Bank[^\n]* 800/); + assertMatch(printed, /2 Add the amounts on line 1[^\n]* 2 2000/); + assertMatch(printed, /\n 4 Subtract line 3 from line 2[\s\S]*? 4 2000/); +}); + +test("TY2026 PDF prints Schedule D and Form 8889 on the 2026 drafts", async () => { + const capital = await text( + await render(fixture("single-direct-broker-basis-sales")), + ); + assertMatch(capital, /SCHEDULE D[\s\S]*2026/); + assertMatch(capital, /go to line 1b[^\n]* 2000 1000 1000/); + assertMatch(capital, /go to Part III on the back[^\n]* 7 1000/); + assertMatch(capital, /go to line 8b[^\n]* 4000 2000 2000/); + assertMatch(capital, /16 Combine lines 7 and 15[^\n]* 16 3000/); + const hsa = await text(await render(fixture("joint-two-hsa-owners"))); + assertMatch(hsa, /Health Savings Accounts[\s\S]*2026/); + assertMatch(hsa, /family coverage\)\. All others[^\n]* 3 4400/); + assertMatch(hsa, /12 Subtract line 11 from line 8[^\n]* 12 4400/); + assertMatch(hsa, /13 HSA deduction[^\n]* 13 4000/); +}); + test("TY2026 PDF refuses, by name, a form with no TY2026 mapping", async () => { await assertRejects( () => render(fixture("single-form2441-child-care-credit")), diff --git a/forms/f1040/2026/pdf/builder.ts b/forms/f1040/2026/pdf/builder.ts index b9e945963..40816daec 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -7,11 +7,28 @@ 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"; +import { scheduleSePdf2026 } from "./forms/taxes/self-employment/schedule_se.ts"; +import { scheduleCPdf2026 } from "./forms/income/business/schedule_c.ts"; +import { form8995Pdf2026 } from "./forms/deductions/business/f8995.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 { form8863Pdf2026 } from "./forms/credits/individual/f8863.ts"; const MAPPED: ReadonlyMap = new Map( - [irs1040Pdf2026, schedule1Pdf2026, schedule2Pdf2026, schedule3Pdf2026].map( - (descriptor) => [descriptor.pendingKey, descriptor], - ), + [ + irs1040Pdf2026, + schedule1Pdf2026, + schedule2Pdf2026, + schedule3Pdf2026, + scheduleBPdf2026, + scheduleCPdf2026, + scheduleDPdf2026, + scheduleSePdf2026, + form8863Pdf2026, + form8889Pdf2026, + form8995Pdf2026, + ].map((descriptor) => [descriptor.pendingKey, descriptor]), ); /** The 2025 print order; forms without a TY2026 mapping refuse when needed. */ diff --git a/forms/f1040/2026/pdf/draft-template.ts b/forms/f1040/2026/pdf/draft-template.ts index ad31ebc5f..2f85f40ae 100644 --- a/forms/f1040/2026/pdf/draft-template.ts +++ b/forms/f1040/2026/pdf/draft-template.ts @@ -28,6 +28,24 @@ function renameEntry( : { ...entry, pdfField: name(entry.pdfField) }; } +/** The base form's pages, shifted past the draft cover sheet. */ +function draftPages( + base: PdfFormDescriptor, + template: Ty2026Template, + fields: Record, +): readonly number[] { + if (!base.pageIndices) return template.formPages; + return base.pageIndices(fields).map((index) => { + const page = template.formPages[index]; + if (page === undefined) { + throw new Error( + `TY2026 PDF ${base.pendingKey} page ${index + 1} is not on the 2026 draft`, + ); + } + return page; + }); +} + /** * 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. @@ -44,7 +62,7 @@ export function onTy2026Template( ...base, pdfUrl: template.pdfUrl, pdfSha256: template.pdfSha256, - pageIndices: () => template.formPages, + pageIndices: (fields) => draftPages(base, template, fields), fields: [ ...base.fields.filter(keep).map((entry) => renameEntry(entry, rename)), ...added, @@ -80,3 +98,27 @@ export function refusedOnTy2026(base: PdfFormDescriptor): PdfFormDescriptor { }, }; } + +/** + * Refuses, by name, when the 2025 projection would append its continuation + * statements, whose page titles still name 2025. + */ +export function withoutStatements( + descriptor: PdfFormDescriptor, + form: string, +): PdfFormDescriptor { + const append = descriptor.appendSupplementalPages; + if (!append) return descriptor; + return { + ...descriptor, + async appendSupplementalPages(document, ...rest) { + const pages = document.getPageCount(); + await append(document, ...rest); + if (document.getPageCount() > pages) { + throw new Error( + `TY2026 PDF ${form} continuation statements are not mapped; export MeF XML`, + ); + } + }, + }; +} diff --git a/forms/f1040/2026/pdf/forms/adjustments/health/f8889.ts b/forms/f1040/2026/pdf/forms/adjustments/health/f8889.ts new file mode 100644 index 000000000..f62908b66 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/adjustments/health/f8889.ts @@ -0,0 +1,16 @@ +import { form8889PdfFor } from "../../../../../2025/pdf/forms/adjustments/health/f8889.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Form 8889 draft (page 1 is the IRS cover sheet). Every field keeps +// its 2025 name and position. +export const form8889Pdf2026: PdfFormDescriptor = onTy2026Template( + form8889PdfFor(2026), + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f8889--dft.pdf", + pdfSha256: + "c94550b7d4b735b11e9901c5dbff5dab0aa6dcbbefab04864c61cd5275cafec8", + formPages: [1], + }, + {}, +); diff --git a/forms/f1040/2026/pdf/forms/credits/individual/f8863.ts b/forms/f1040/2026/pdf/forms/credits/individual/f8863.ts new file mode 100644 index 000000000..eb006191e --- /dev/null +++ b/forms/f1040/2026/pdf/forms/credits/individual/f8863.ts @@ -0,0 +1,18 @@ +import { form8863PdfFor } from "../../../../../2025/pdf/forms/credits/individual/f8863.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Form 8863 draft (page 1 is the IRS cover sheet). Page 1 lines move +// down but keep their names; the student name field is renamed f2_5. +export const form8863Pdf2026: PdfFormDescriptor = onTy2026Template( + form8863PdfFor(2026), + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f8863--dft.pdf", + pdfSha256: + "b037225d76b74a73740398c3ea757d2cde892d4fc1cfe65da35659de8bbddd90", + formPages: [1, 2], + }, + { + "topmostSubform[0].Page2[0].f2-5[0]": "topmostSubform[0].Page2[0].f2_5[0]", + }, +); diff --git a/forms/f1040/2026/pdf/forms/deductions/business/f8995.ts b/forms/f1040/2026/pdf/forms/deductions/business/f8995.ts new file mode 100644 index 000000000..0478d6173 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/deductions/business/f8995.ts @@ -0,0 +1,72 @@ +import { form8995PdfFor } from "../../../../../2025/pdf/forms/deductions/business/f8995/f8995.ts"; +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template, withoutStatements } from "../../../draft-template.ts"; + +// IRS 2026 Form 8995 draft (page 1 is the IRS cover sheet). Lines 1-15 keep +// their fields; new line 16 is the IRC 199A(i) minimum deduction and line 17 +// the deduction, so the two loss carryforwards move to lines 18 and 19. +const P1 = "topmostSubform[0].Page1[0]"; + +const rename: Readonly> = { + ...Object.fromEntries( + Array.from({ length: 9 }, (_, index) => { + const number = index + 1; + const row = ["", "", "Table[0].Row1i[0].", "Table[0].Row1i[0].", + "Table[0].Row1i[0].", "Table[0].Row1ii[0].", "Table[0].Row1ii[0].", + "Table[0].Row1ii[0].", "Table[0].Row1iii[0]."][index]; + return [ + `${P1}.${row}f1_0${number}[0]`, + `${P1}.${row}f1_${number}[0]`, + ]; + }), + ), + [`${P1}.Line2_ReadOrder[0].f1_18[0]`]: `${P1}.f1_18[0]`, + [`${P1}.Line6_ReadOrder[0].f1_22[0]`]: `${P1}.f1_22[0]`, +}; + +const added = [16, 17, 18, 19].map((line): PdfFieldEntry => ({ + kind: "text", + domainKey: `ty2026_line${line}`, + pdfField: `${P1}.f1_${line + 16}[0]`, + printZero: true, +})); + +const base = form8995PdfFor(2026); +const draft = withoutStatements( + onTy2026Template( + base, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f8995--dft.pdf", + pdfSha256: + "d698390e2990de21b8df9ccb485f9309b817707d4871581118337bf7c1b14fd8", + formPages: [1], + }, + rename, + ["line16", "line17"], + added, + ), + "Form 8995", +); + +export const form8995Pdf2026: PdfFormDescriptor = { + ...draft, + projectFields(fields, allPending) { + if (fields.minimum_deduction !== undefined) { + throw new Error( + "TY2026 PDF Form 8995 line 16 minimum deduction is not mapped; export MeF XML", + ); + } + const projected = base.projectFields?.(fields, allPending) ?? fields; + if (typeof projected.line15 !== "number") return projected; + return { + ...projected, + ty2026_line16: 0, + ty2026_line17: projected.line15, + ty2026_line18: projected.line16, + ty2026_line19: projected.line17, + }; + }, +}; diff --git a/forms/f1040/2026/pdf/forms/income/business/schedule_c.ts b/forms/f1040/2026/pdf/forms/income/business/schedule_c.ts new file mode 100644 index 000000000..bf4238d37 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/income/business/schedule_c.ts @@ -0,0 +1,114 @@ +import { scheduleCPdfFor } from "../../../../../2025/pdf/forms/income/business/schedule_c.ts"; +import type { + PdfFieldEntry, + PdfFormDescriptor, +} from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Schedule C draft (page 1 is the IRS cover sheet). Line E splits +// the address into street, apt., city, state and ZIP; line F gains "Other"; +// line 16 becomes 16a mortgage, 16b vehicle loan and 16c other, so the +// fields after line E renumber. The engine keeps one "other interest" amount, +// printed on 16c. Part V rows leave their table container. +const P1 = "topmostSubform[0].Page1[0]"; +const P2 = "topmostSubform[0].Page2[0]"; + +const lineE: ReadonlyArray = [ + ["ty2026_business_street", `${P1}.f1_7[0]`], + ["ty2026_business_apt", `${P1}.f1_8[0]`], + ["ty2026_business_city", `${P1}.f1_9[0]`], + ["ty2026_business_state", `${P1}.f1_10[0]`], + ["ty2026_business_zip", `${P1}.f1_11[0]`], +]; + +const rename: Readonly> = { + [`${P1}.BComb[0].f1_4[0]`]: `${P1}.f1_4[0]`, + [`${P1}.DComb[0].f1_6[0]`]: `${P1}.BoxD[0].f1_6[0]`, + ...Object.fromEntries( + Array.from({ length: 7 }, (_, index) => [ + `${P1}.f1_${10 + index}[0]`, + `${P1}.f1_${13 + index}[0]`, + ]), + ), + ...Object.fromEntries( + [20, 21, 22, 23, 24, 25, 26, 27, 28, 31, 32].map((number, index) => [ + `${P1}.Lines8-17[0].f1_${17 + index}[0]`, + `${P1}.Lines8-16c_ReadOrder[0].f1_${number}[0]`, + ]), + ), + ...Object.fromEntries( + Array.from({ length: 11 }, (_, index) => [ + `${P1}.Lines18-27[0].f1_${28 + index}[0]`, + `${P1}.f1_${33 + index}[0]`, + ]), + ), + [`${P1}.Lines18-27[0].f1_40[0]`]: `${P1}.f1_44[0]`, + [`${P1}.Lines18-27[0].f1_39[0]`]: `${P1}.f1_45[0]`, + [`${P1}.f1_41[0]`]: `${P1}.f1_46[0]`, + [`${P1}.f1_42[0]`]: `${P1}.f1_47[0]`, + [`${P1}.Line30_ReadOrder[0].f1_43[0]`]: `${P1}.Line30_ReadOrder[0].f1_48[0]`, + [`${P1}.Line30_ReadOrder[0].f1_44[0]`]: `${P1}.Line30_ReadOrder[0].f1_49[0]`, + [`${P1}.f1_45[0]`]: `${P1}.f1_50[0]`, + [`${P1}.f1_46[0]`]: `${P1}.f1_51[0]`, +}; + +const base = scheduleCPdfFor(2026); +const draft = onTy2026Template( + base, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040sc--dft.pdf", + pdfSha256: + "287b4510dd847c11f6cbbcc98fee05ac5ff187cc18e1413f8759a4a7cd802224", + formPages: [1, 2], + }, + rename, + ["business_street", "business_city_state_zip"], + lineE.map(([domainKey, pdfField]): PdfFieldEntry => ({ + kind: "text", + domainKey, + pdfField, + })), +); + +function splitAddress(copy: Record): Record { + const address = copy.line_e_business_address as + | { line1: string; line2?: string; city: string; state: string; zip: string } + | undefined; + if (!address) return copy; + return { + ...copy, + ty2026_business_street: address.line1, + ty2026_business_apt: address.line2, + ty2026_business_city: address.city, + ty2026_business_state: address.state, + ty2026_business_zip: address.zip, + }; +} + +export const scheduleCPdf2026: PdfFormDescriptor = { + ...draft, + projectFields(raw, allPending) { + const projected = base.projectFields?.(raw, allPending) ?? raw; + const copies = projected.schedule_c_instances as Record[]; + return { ...projected, schedule_c_instances: copies.map(splitAddress) }; + }, + rows: { + domainKey: "part_v_other_expenses", + maxRows: 9, + rowStride: 2, + rowFields: [ + { + kind: "text", + domainKey: "description", + pdfFieldPattern: `${P2}.f2_{field_num}[0]`, + fieldNumBase: 15, + }, + { + kind: "text", + domainKey: "amount", + pdfFieldPattern: `${P2}.f2_{field_num}[0]`, + fieldNumBase: 16, + }, + ], + }, +}; diff --git a/forms/f1040/2026/pdf/forms/income/investments/schedule_b.ts b/forms/f1040/2026/pdf/forms/income/investments/schedule_b.ts new file mode 100644 index 000000000..61a392b31 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/income/investments/schedule_b.ts @@ -0,0 +1,19 @@ +import { scheduleBPdf } from "../../../../../2025/pdf/forms/income/investments/schedule_b/schedule_b.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template, withoutStatements } from "../../../draft-template.ts"; + +// IRS 2026 Schedule B draft (page 1 is the IRS cover sheet). Every field keeps +// its 2025 name and position, so the Form 8814 notes land on the same dots. +export const scheduleBPdf2026: PdfFormDescriptor = withoutStatements( + onTy2026Template( + scheduleBPdf, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040sb--dft.pdf", + pdfSha256: + "aa6272f6b3b8a8c6a2aa6ef9d1069f1edb88fb9df4df60278d198da89cdfd93f", + formPages: [1], + }, + {}, + ), + "Schedule B", +); diff --git a/forms/f1040/2026/pdf/forms/income/investments/schedule_d.ts b/forms/f1040/2026/pdf/forms/income/investments/schedule_d.ts new file mode 100644 index 000000000..51f3e51f9 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/income/investments/schedule_d.ts @@ -0,0 +1,16 @@ +import { scheduleDPdf } from "../../../../../2025/pdf/forms/income/investments/schedule_d.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Schedule D draft (page 1 is the IRS cover sheet). Every field keeps +// its 2025 name and position, including line 13's dotted space. +export const scheduleDPdf2026: PdfFormDescriptor = onTy2026Template( + scheduleDPdf, + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040sd--dft.pdf", + pdfSha256: + "0df9af0711964b3198ea04bb0037b29d6afa85578a671553daed46251630b5f1", + formPages: [1, 2], + }, + {}, +); diff --git a/forms/f1040/2026/pdf/forms/taxes/self-employment/schedule_se.ts b/forms/f1040/2026/pdf/forms/taxes/self-employment/schedule_se.ts new file mode 100644 index 000000000..b6e88ddfe --- /dev/null +++ b/forms/f1040/2026/pdf/forms/taxes/self-employment/schedule_se.ts @@ -0,0 +1,19 @@ +import { scheduleSePdfFor } from "../../../../../2025/pdf/forms/taxes/self-employment/schedule_se.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Schedule SE draft (page 1 is the IRS cover sheet). Lines and +// widgets match 2025; only line 8a left its read-order container. +export const scheduleSePdf2026: PdfFormDescriptor = onTy2026Template( + scheduleSePdfFor(2026), + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f1040sse--dft.pdf", + pdfSha256: + "a59fa5dcf2f0a8a5a71c46aa3837e9e8e605eeecc0a666bcb784a368900d141c", + formPages: [1, 2], + }, + { + "topmostSubform[0].Page1[0].Line8a_ReadOrder[0].f1_14[0]": + "topmostSubform[0].Page1[0].f1_14[0]", + }, +); From 6d046b51361a6ee0224a3705b0b79d7e67299f0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:46:35 +0000 Subject: [PATCH 19/26] feat(ty2026): PDF export of Form 2441 on the IRS 2026 draft Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../pdf/forms/credits/individual/f2441.ts | 10 +++-- forms/f1040/2026/pdf/builder.test.ts | 19 ++++++++- forms/f1040/2026/pdf/builder.ts | 2 + .../pdf/forms/credits/individual/f2441.ts | 41 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 forms/f1040/2026/pdf/forms/credits/individual/f2441.ts diff --git a/forms/f1040/2025/pdf/forms/credits/individual/f2441.ts b/forms/f1040/2025/pdf/forms/credits/individual/f2441.ts index c15310287..ff7cb1b29 100644 --- a/forms/f1040/2025/pdf/forms/credits/individual/f2441.ts +++ b/forms/f1040/2025/pdf/forms/credits/individual/f2441.ts @@ -70,7 +70,8 @@ const fields: PdfFieldEntry[] = [ ]; /** The supported no-benefit route prints the filing page and its source rows. */ -export const form2441Pdf: PdfFormDescriptor = { +export function form2441PdfFor(taxYear: number): PdfFormDescriptor { + return { pendingKey: "form2441", pdfUrl: "https://www.irs.gov/pub/irs-prior/f2441--2025.pdf", pageIndices: () => [0], @@ -94,7 +95,7 @@ export const form2441Pdf: PdfFormDescriptor = { if (typeof agi !== "number" || (raw.agi !== undefined && raw.agi !== agi)) { throw new Error("Form 2441 PDF needs final Form 1040 AGI"); } - const lines = calculateForm2441(details, agi, 0, CONFIG_BY_YEAR[2025]); + const lines = calculateForm2441(details, agi, 0, CONFIG_BY_YEAR[taxYear]); if (allPending.schedule3?.line2_childcare_credit !== lines.line11) { throw new Error("Form 2441 PDF credit differs from Schedule 3 line 2"); } @@ -142,4 +143,7 @@ export const form2441Pdf: PdfFormDescriptor = { }, includeWhen: (fields) => typeof fields.line11 === "number" && fields.line11 > 0, -}; + }; +} + +export const form2441Pdf: PdfFormDescriptor = form2441PdfFor(2025); diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index 8821022a7..e4d6074d1 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -206,11 +206,26 @@ test("TY2026 PDF prints Schedule D and Form 8889 on the 2026 drafts", async () = assertMatch(hsa, /13 HSA deduction[^\n]* 13 4000/); }); +test("TY2026 PDF prints Form 2441 Part I providers as columns", async () => { + const printed = await text( + await render(fixture("single-form2441-child-care-credit")), + ); + assertMatch(printed, /Child and Dependent Care Expenses[\s\S]*2026/); + assertMatch(printed, /1a Care provider’s name Care Center/); + assertMatch(printed, /1b Address \(number and street\) 100 Main St/); + assertMatch(printed, /1c Identifying number \(SSN or EIN\) 123456789/); + assertMatch(printed, /1e Amount paid\. See instructions\. 3000/); + assertMatch(printed, /Ada Example 111223334 3000/); + assertMatch(printed, /line 11a 7 50000/); + assertMatch(printed, /9a Multiply line 6[^\n]* 9a 1050/); + assertMatch(printed, /on Schedule 3 \(Form 1040\), line 2[^\n]* 11 500/); +}); + test("TY2026 PDF refuses, by name, a form with no TY2026 mapping", async () => { await assertRejects( - () => render(fixture("single-form2441-child-care-credit")), + () => render(fixture("single-child-unearned-income")), Error, - "TY2026 PDF export needs form2441", + "TY2026 PDF export needs form8615", ); }); diff --git a/forms/f1040/2026/pdf/builder.ts b/forms/f1040/2026/pdf/builder.ts index 40816daec..4c3ee151c 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -10,6 +10,7 @@ import { schedule2Pdf2026 } from "./forms/taxes/other/schedule2.ts"; import { scheduleSePdf2026 } from "./forms/taxes/self-employment/schedule_se.ts"; import { scheduleCPdf2026 } from "./forms/income/business/schedule_c.ts"; import { form8995Pdf2026 } from "./forms/deductions/business/f8995.ts"; +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"; @@ -25,6 +26,7 @@ const MAPPED: ReadonlyMap = new Map( scheduleCPdf2026, scheduleDPdf2026, scheduleSePdf2026, + form2441Pdf2026, form8863Pdf2026, form8889Pdf2026, form8995Pdf2026, diff --git a/forms/f1040/2026/pdf/forms/credits/individual/f2441.ts b/forms/f1040/2026/pdf/forms/credits/individual/f2441.ts new file mode 100644 index 000000000..0301a7206 --- /dev/null +++ b/forms/f1040/2026/pdf/forms/credits/individual/f2441.ts @@ -0,0 +1,41 @@ +import { form2441PdfFor } from "../../../../../2025/pdf/forms/credits/individual/f2441.ts"; +import type { PdfFormDescriptor } from "../../../../../2025/pdf/review-support/form-descriptor.ts"; +import { onTy2026Template } from "../../../draft-template.ts"; + +// IRS 2026 Form 2441 draft (page 1 is the IRS cover sheet). Part I turns its +// provider table sideways: lines 1a-1e are rows and providers are columns. +// Line 2 onward keeps its 2025 fields. +const P1 = "topmostSubform[0].Page1[0]"; +const old = (provider: number) => `${P1}.PartITable[0].BodyRow${provider}[0]`; +const row = (line: string) => `${P1}.PartITable[0].Row${line}[0]`; + +const rename: Readonly> = Object.fromEntries( + [1, 2, 3].flatMap((provider) => { + const at = (provider - 1) * 4; + return [ + [`${old(provider)}.f1_${3 + at}[0]`, + `${row("1a")}.Provider${provider}[0].f1_${2 + provider}[0]`], + [`${old(provider)}.ColB[0].f1_${4 + at}[0]`, + `${row("1b")}.Provider${provider}[0].f1_${5 + provider}[0]`], + [`${old(provider)}.f1_${5 + at}[0]`, + `${row("1c")}.f1_${8 + provider}[0]`], + ...[0, 1].map((box) => [ + `${old(provider)}.ColD[0].c1_${3 + provider}[${box}]`, + `${row("1d")}.Provider${provider}[0].c1_${3 + provider}[${box}]`, + ]), + [`${old(provider)}.f1_${6 + at}[0]`, + `${row("1e")}.f1_${11 + provider}[0]`], + ]; + }), +); + +export const form2441Pdf2026: PdfFormDescriptor = onTy2026Template( + form2441PdfFor(2026), + { + pdfUrl: "https://www.irs.gov/pub/irs-dft/f2441--dft.pdf", + pdfSha256: + "67eca7567ce5ff06e72d40a07db487f406da33139e664c0c0d9c67a4e31e9385", + formPages: [1, 2], + }, + rename, +); From bfa7d7bf77398deb16255ec2d007e8a454ea63f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:46:59 +0000 Subject: [PATCH 20/26] docs(ty2026): list the PDF forms printed on 2026 drafts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- forms/f1040/2026/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/forms/f1040/2026/README.md b/forms/f1040/2026/README.md index e886c0730..ed71626ba 100644 --- a/forms/f1040/2026/README.md +++ b/forms/f1040/2026/README.md @@ -6,11 +6,13 @@ normalization with the 2026v1.0 schema. It accepts every input TY2025 accepts; 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 +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 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. +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. 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. From 59af497f66cdf6d08ee1ebb7081630cd5312cba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:47:22 +0000 Subject: [PATCH 21/26] chore(structure): shrink the year-pinned baseline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- scripts/testing/year-pinned-baseline.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/scripts/testing/year-pinned-baseline.json b/scripts/testing/year-pinned-baseline.json index e4d98390c..583caf80c 100644 --- a/scripts/testing/year-pinned-baseline.json +++ b/scripts/testing/year-pinned-baseline.json @@ -729,11 +729,6 @@ "pattern": "year-identifier", "count": 4 }, - { - "path": "forms/f1040/2025/pdf/forms/credits/individual/f2441.ts", - "pattern": "config-index", - "count": 1 - }, { "path": "forms/f1040/2025/pdf/forms/credits/individual/f8862.ts", "pattern": "year-value", @@ -764,11 +759,6 @@ "pattern": "year-identifier", "count": 3 }, - { - "path": "forms/f1040/2025/pdf/forms/income/business/schedule_c.ts", - "pattern": "config-index", - "count": 1 - }, { "path": "forms/f1040/2025/pdf/forms/income/foreign/f8621_parent_source.ts", "pattern": "date-pinned", @@ -834,11 +824,6 @@ "pattern": "year-identifier", "count": 2 }, - { - "path": "forms/f1040/2025/pdf/forms/taxes/self-employment/schedule_se.ts", - "pattern": "config-index", - "count": 1 - }, { "path": "forms/f1040/2025/pdf/forms/taxes/underpayment/f2210_box_e.ts", "pattern": "year-identifier", From 6b0571ed880490f7ef437baca9d8a83ebed47926 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:08:25 +0000 Subject: [PATCH 22/26] feat(ty2026): PDF export of Schedule E on the IRS 2026 draft Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../income/rental-passthrough/schedule_e.ts | 11 +- forms/f1040/2026/pdf/builder.test.ts | 47 ++++ forms/f1040/2026/pdf/builder.ts | 2 + .../income/rental-passthrough/schedule_e.ts | 225 ++++++++++++++++++ 4 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 forms/f1040/2026/pdf/forms/income/rental-passthrough/schedule_e.ts 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..0f0cd2da9 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 {}; @@ -692,4 +692,7 @@ export const scheduleEPdf: PdfFormDescriptor = { filer, ); }, -}; + }; +} + +export const scheduleEPdf: PdfFormDescriptor = scheduleEPdfFor(2025); diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index e4d6074d1..3aab69de5 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,52 @@ 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 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..3de928682 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -14,6 +14,7 @@ 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 { form8863Pdf2026 } from "./forms/credits/individual/f8863.ts"; const MAPPED: ReadonlyMap = new Map( @@ -25,6 +26,7 @@ const MAPPED: ReadonlyMap = new Map( scheduleBPdf2026, scheduleCPdf2026, scheduleDPdf2026, + scheduleEPdf2026, scheduleSePdf2026, form2441Pdf2026, form8863Pdf2026, 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", +); From 57c6270804036f8c3922a595099bf52a99933abc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:12:55 +0000 Subject: [PATCH 23/26] feat(ty2026): PDF export of Schedule A on the IRS 2026 draft Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../forms/deductions/itemized/schedule_a.ts | 21 ++- forms/f1040/2026/pdf/builder.test.ts | 21 +++ forms/f1040/2026/pdf/builder.ts | 2 + .../forms/deductions/itemized/schedule_a.ts | 139 ++++++++++++++++++ 4 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 forms/f1040/2026/pdf/forms/deductions/itemized/schedule_a.ts 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/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index 3aab69de5..33549fdc9 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -268,6 +268,27 @@ test("TY2026 PDF prints Schedule E on the 2026 draft pages", async () => { }); }); +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 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 3de928682..ea0258568 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -15,6 +15,7 @@ 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 { form8863Pdf2026 } from "./forms/credits/individual/f8863.ts"; const MAPPED: ReadonlyMap = new Map( @@ -23,6 +24,7 @@ const MAPPED: ReadonlyMap = new Map( schedule1Pdf2026, schedule2Pdf2026, schedule3Pdf2026, + scheduleAPdf2026, scheduleBPdf2026, scheduleCPdf2026, scheduleDPdf2026, 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 ?? {})); + }, +}; From 169eec219676b87e4c2febdfeae8cd197b265874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:15:24 +0000 Subject: [PATCH 24/26] feat(ty2026): PDF export of Schedule 1-A car loan and senior parts on the IRS 2026 draft Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- .../additional/schedule1a/schedule1a.ts | 20 +-- .../income/rental-passthrough/schedule_e.ts | 11 +- forms/f1040/2026/README.md | 7 +- forms/f1040/2026/pdf/builder.test.ts | 26 ++++ forms/f1040/2026/pdf/builder.ts | 2 + .../forms/deductions/additional/schedule1a.ts | 114 ++++++++++++++++++ 6 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 forms/f1040/2026/pdf/forms/deductions/additional/schedule1a.ts 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/income/rental-passthrough/schedule_e.ts b/forms/f1040/2025/pdf/forms/income/rental-passthrough/schedule_e.ts index 0f0cd2da9..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 @@ -297,7 +297,7 @@ export function scheduleEPdfFor(taxYear: number): 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 function scheduleEPdfFor(taxYear: number): 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 function scheduleEPdfFor(taxYear: number): 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 function scheduleEPdfFor(taxYear: number): 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 function scheduleEPdfFor(taxYear: number): 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 function scheduleEPdfFor(taxYear: number): 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", 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 38c02e750..87425aa58 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -288,6 +288,32 @@ test("TY2026 PDF prints Schedule A with the 0.5% gift floor and line 18", async 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 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 ea0258568..8c95d33cb 100644 --- a/forms/f1040/2026/pdf/builder.ts +++ b/forms/f1040/2026/pdf/builder.ts @@ -16,12 +16,14 @@ 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, 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", +); From 01d57319f0771d14a7a1a732d6c32938221bd8a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:16:32 +0000 Subject: [PATCH 25/26] test(ty2026): render Form 8863 on the IRS 2026 draft Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- forms/f1040/2026/pdf/builder.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/forms/f1040/2026/pdf/builder.test.ts b/forms/f1040/2026/pdf/builder.test.ts index 87425aa58..97ad52cc5 100644 --- a/forms/f1040/2026/pdf/builder.test.ts +++ b/forms/f1040/2026/pdf/builder.test.ts @@ -314,6 +314,20 @@ test("TY2026 PDF prints Schedule 1-A car loan and senior parts", async () => { ); }); +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")), From 2092acc96e739d687ba0fb5c9d4cfa1bdbfa2b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:16:59 +0000 Subject: [PATCH 26/26] chore(structure): shrink the year-pinned baseline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sm4GSdgHFQ4sJqLdRaK6pQ --- scripts/testing/year-pinned-baseline.json | 10 ---------- 1 file changed, 10 deletions(-) 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",