Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b28d9cb
fix(f1040): Form 8958 staged projection runs under the return's year
claude Oct 19, 2026
4813af8
fix(f1040): Schedule J source replay takes the return's context
claude Oct 19, 2026
028ba3c
fix(f1040): Form 8990 two-pass and its MeF/PDF reconcile take the ret…
claude Oct 19, 2026
b05ba91
fix(f1040): Form 7203 prior reduced note execution takes the return's…
claude Oct 19, 2026
3490b1e
Merge remote-tracking branch 'origin/main' into ag39-ty26-staged
claude Oct 19, 2026
1dba827
chore: shrink year-pinned baseline
claude Oct 19, 2026
320a293
fix(cli): Form 8990 workpaper reconciles under the return's year
claude Oct 19, 2026
da0d36f
fix(f1040): Form 4972 and Schedule A line 16 reconcile under the retu…
claude Oct 19, 2026
d88ed0b
fix(f1040): Form 982 QPRI threads the return's year and refuses TY2026
claude Oct 19, 2026
c0f4117
fix(f1040): Form 172 workpapers thread the return's year and refuse T…
claude Oct 19, 2026
6160567
fix(f1040): Form 8621 replays take the return's context, no TY2025 de…
claude Oct 19, 2026
75bfb2f
fix(f1040): passive S-corp copies replay Form 8582 and AGI under the …
claude Oct 19, 2026
91073f8
chore: shrink year-pinned baseline
claude Oct 19, 2026
95d8e42
Merge origin/main into ag40-ty26-reconcilers
claude Oct 19, 2026
154d9b1
test(f1040): type the TY2026 participant collection check
claude Oct 19, 2026
82fc12c
Merge origin/main into ag41-ty26-pdf-scope
claude Oct 19, 2026
0de56ae
refactor(pdf): PDF builder and Form 1040/Schedule 3 projections take …
claude Oct 19, 2026
22ccb52
feat(ty2026): PDF export of Form 1040 and Schedules 1-3 on IRS 2026 d…
claude Oct 19, 2026
f301410
fix(ty2026): load the 2025 PDF modules on use so the compiled binary …
claude Oct 19, 2026
dc0f1ec
test(release): smoke a TY2026 draft PDF export from the binary
claude Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ export async function exportMefCommand(
export type ExportPdfArgs = ExportReturnArgs & {
/** Output file path. Defaults to <baseDir>/<returnId>/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(
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ const COMMANDS: readonly CommandDef[] = [
force,
draft,
outputPath,
notice: (message) => console.log(message),
});
console.log(`PDF written to ${writtenPath}`);
});
Expand Down
2 changes: 2 additions & 0 deletions core/types/form-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TaxNode>;
/** Single form-owned execution entrypoint for validated source inputs. */
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<section>/<group>/<form>/ # Validated source nodes
Expand Down
48 changes: 33 additions & 15 deletions forms/f1040/2025/pdf/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PdfFormDescriptor>;
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;
Expand Down Expand Up @@ -577,6 +590,7 @@ async function buildPdfBytesInternal(
preparedBundleInput?: MefBundle,
pageOrigins?: PdfPageOrigin[],
paperOnly4972 = false,
options: PdfBuildOptions = DEFAULT_PDF_BUILD,
): Promise<Uint8Array> {
// Preserve the authenticated disclosure identity and XML throughout awaits.
const preparedBundle = preparedBundleInput
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -646,7 +660,7 @@ async function buildPdfBytesInternal(
assertTaxExemptInterestSource(normalized);
assertBusinessSchedule1Amounts(normalized);
assertOwned7203RequiredCopies(normalized, {
taxYear: mefTaxYear(),
taxYear: options.taxYear,
formType: "f1040",
});
assertHsaExcessRequiredCopy(normalized);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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, []);
}
Expand All @@ -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,
Expand All @@ -771,7 +785,7 @@ async function buildPdfBytesInternal(
preparedBundle.pending,
filer,
preparedBundle.attachments,
mefTaxYear(),
options.taxYear,
);
if (
(preparedBundle.form3800Parts === undefined) !==
Expand Down Expand Up @@ -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<string, number>();

for (const registeredDescriptor of ALL_PDF_FORMS) {
for (const registeredDescriptor of options.descriptors) {
const descriptor = paperOnly4972 &&
registeredDescriptor.pendingKey === "form4972"
? form4972PaperPdf
Expand Down Expand Up @@ -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();
}
Expand All @@ -1105,13 +1120,16 @@ export function buildPdfBytes(
cacheDir = ".pdf-cache",
preparedBundle?: MefBundle,
pageOrigins?: PdfPageOrigin[],
options: PdfBuildOptions = DEFAULT_PDF_BUILD,
): Promise<Uint8Array> {
return buildPdfBytesInternal(
pending,
filer,
cacheDir,
preparedBundle,
pageOrigins,
false,
options,
);
}

Expand Down
32 changes: 19 additions & 13 deletions forms/f1040/2025/pdf/forms/general/return-assembly/f1040.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ const fields: ReadonlyArray<PdfFieldEntry> = [
function line1hType(
fields: Record<string, unknown>,
allPending: Record<string, Record<string, unknown>>,
taxYear: number,
):
| "FEC"
| "EXCESS DEFERRALS"
Expand Down Expand Up @@ -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 });
Expand All @@ -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({
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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 &&
Expand All @@ -832,16 +835,16 @@ 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<string, unknown> | undefined,
);
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;
Expand All @@ -861,7 +864,7 @@ export const irs1040Pdf: PdfFormDescriptor = {
const printFormerSpouseEstimatedTaxSsn = assertEstimatedPaymentLine26(
fields,
allPending,
2025,
taxYear,
);
assertOtherFormsWithholding(fields, allPending);
assertReturnScheduleJoins(fields, allPending);
Expand Down Expand Up @@ -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`] =
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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());
12 changes: 9 additions & 3 deletions forms/f1040/2025/pdf/forms/general/return-assembly/schedule3.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -176,7 +177,9 @@ const fields: ReadonlyArray<PdfFieldEntry> = [
},
];

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,
Expand All @@ -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,
Expand Down Expand Up @@ -222,4 +225,7 @@ export const schedule3Pdf: PdfFormDescriptor = {
pdfField: "topmostSubform[0].Page1[0].f1_02[0]",
},
],
};
};
}

export const schedule3Pdf: PdfFormDescriptor = schedule3PdfFor(mefTaxYear());
11 changes: 10 additions & 1 deletion forms/f1040/2026/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 4 additions & 2 deletions forms/f1040/2026/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading