Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4bbb508
fix(form7206): figure self-employed health insurance from premiums an…
a7ul Oct 19, 2026
89b1365
fix(f1040): an incomplete Form 8880 leaves the credit unclaimed inste…
a7ul Oct 19, 2026
bda9831
fix(w2): count the age catch-up before requiring a code D excess review
a7ul Oct 19, 2026
fda0689
fix(w2): keep a W-2 with box 6 but no box 5 and warn about the wage base
a7ul Oct 19, 2026
cfbb704
fix(form8995a): compare patron W-2 copies without the joined birth date
a7ul Oct 19, 2026
a3a1162
fix(nol): a fully used NOL carryforward does not need the base standa…
a7ul Oct 19, 2026
c0e18f8
feat(w2): report a box 11 NQDC or nongovernmental 457 distribution on…
a7ul Oct 19, 2026
4620d63
feat(f1099g): figure the taxable state refund from prior-year Schedul…
a7ul Oct 19, 2026
e2c56bd
fix(form4952): count unmarked 1099 interest and dividends as investme…
a7ul Oct 19, 2026
5d7469c
feat(f1099r): take a prior-year Roth contribution basis for code J di…
a7ul Oct 19, 2026
42f50d2
fix(schedule_c): keep line 15 insurance and Part V beside Form 8829
a7ul Oct 19, 2026
cad77a5
fix(schedule_c): add the auto_expense line 9 amount to the business
a7ul Oct 19, 2026
1fc4965
fix(schedule_e): put auto_expense rental car expense on the rental's …
a7ul Oct 19, 2026
e134b23
fix(schedule_e): divide a mixed-use unit's expenses by rental days
a7ul Oct 19, 2026
99d16df
feat(schedule_c): DOT hours-of-service meals at 80% beside ordinary m…
a7ul Oct 19, 2026
3ccc4f1
fix(form8995): figure QBI across Schedule C, K-1 and REIT sources
a7ul Oct 19, 2026
2f63cea
fix(form8995): carry a current-year net QBI loss forward instead of t…
a7ul Oct 19, 2026
e3104ff
fix(form8995a): accept REIT dividends from several reviewed funds
a7ul Oct 19, 2026
133f10f
fix(cli): keep the Schedule A line 9 Form 4952 hint when 1099s feed F…
a7ul Oct 19, 2026
282c82b
fix(form6251): ignore default 1099 sources when checking for an enter…
a7ul Oct 19, 2026
38c7d05
Revert "fix(form6251): ignore default 1099 sources when checking for …
a7ul Oct 19, 2026
9d3d6a5
Revert "fix(cli): keep the Schedule A line 9 Form 4952 hint when 1099…
a7ul Oct 19, 2026
3dbc425
Revert "fix(form4952): count unmarked 1099 interest and dividends as …
a7ul Oct 19, 2026
365bb6a
Revert "fix(form8995): carry a current-year net QBI loss forward inst…
a7ul Oct 19, 2026
7fde66b
Revert "fix(form8995a): accept REIT dividends from several reviewed f…
a7ul Oct 19, 2026
c37e501
Revert "fix(form8995): figure QBI across Schedule C, K-1 and REIT sou…
a7ul 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
7 changes: 5 additions & 2 deletions cli/commands/form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,16 @@ test("SEHI CLI stores the Marketplace answer in the canonical singleton", async
returnId,
nodeType: "self_employed_health_insurance",
dataJson: JSON.stringify({
items: [{ premiums_paid: 1200 }],
items: [{
premiums_paid: 1200,
employer_plan_eligible_month_premiums: 1300,
}],
marketplace_ptc_premium_overlap: false,
}),
baseDir: tmpDir,
}),
Error,
"enter form7206 instead",
"cannot exceed premiums paid",
);
await assertRejects(
() =>
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/return.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ export async function getReturnCommand(
for (const d of result.diagnostics) {
warnings.push(`[${d.code}] ${d.nodeType}: ${d.message}`);
}
for (const w of result.warnings ?? []) {
warnings.push(`[NODE_WARNING] ${w.nodeType}: ${w.message}`);
}

return {
returnId: meta.returnId,
Expand Down
13 changes: 13 additions & 0 deletions core/runtime/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ export type ExecutorDiagnosticEntry = {
readonly message: string;
};

/** A node's note about an incomplete source; the return still computed. */
export type ExecutorWarningEntry = {
readonly nodeType: string;
readonly nodeId: string;
readonly message: string;
};

export type ExecuteResult = {
readonly pending: Readonly<Record<string, Record<string, unknown>>>;
readonly diagnostics: readonly ExecutorDiagnosticEntry[];
readonly warnings?: readonly ExecutorWarningEntry[];
readonly carryforwards: Readonly<Record<string, number>>;
readonly replayInputs?: Readonly<
Record<string, Readonly<Record<string, unknown>>>
Expand Down Expand Up @@ -136,6 +144,7 @@ export function execute(
pending["start"] = { ...inputs };

const diagnostics: ExecutorDiagnosticEntry[] = [];
const warnings: ExecutorWarningEntry[] = [];
const carryforwards: Record<string, number> = {};
const replayInputs: Record<string, Record<string, unknown>> = {};

Expand Down Expand Up @@ -198,6 +207,9 @@ export function execute(
true,
);
}
for (const message of result.warnings ?? []) {
warnings.push({ nodeType: step.nodeType, nodeId: step.id, message });
}
if (result.carryforwards) {
Object.assign(carryforwards, result.carryforwards);
}
Expand All @@ -217,6 +229,7 @@ export function execute(
pending: withoutContextOnlySlots(pending, registry),
diagnostics,
carryforwards,
...(warnings.length > 0 ? { warnings } : {}),
...(Object.keys(replayInputs).length > 0 ? { replayInputs } : {}),
};
}
Expand Down
2 changes: 2 additions & 0 deletions core/types/tax-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type NodeResult = {
readonly finalizations?: readonly NodeOutput[];
// Carryforward amounts to next tax year (key = descriptive label, value = positive amount)
readonly carryforwards?: Readonly<Record<string, number>>;
/** Review notes for an incomplete source that did not stop the return. */
readonly warnings?: readonly string[];
};

// Requires at least one key from T to be present
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ export function assertForm8995APatronReturn(
? w2Schema.parse({ w2s: patronW2Sources(review) }).w2s
: [];
const w2Input = pending.w2 ? w2Schema.parse(pending.w2) : undefined;
const w2s = w2Input?.w2s ?? [];
// Birth dates from the general information join each parsed copy.
const w2s = (w2Input?.w2s ?? []).map(({ owner_birth_date: _, ...row }) =>
row
);
if (
pending.general?.filing_status !== input.filing_status ||
owner !== review.source_1099patr.recipient_tin ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,25 @@ test("NOL limit rejects a return whose deduction is not the assumed standard ded
"enter nol_carryforward current_year_taxable_income",
);
});

// Synthetic single filer aged 66 (17,750 standard deduction: 15,750 + 2,000
// additional; 2025 Form 1040 instructions) with a 2017 NOL of 20,000. A
// pre-2018 loss has no 80% limit (IRC §172(a)(1); Pub. 536), and taxable
// income of 102,250 absorbs it, so the deduction is 20,000 whatever the
// standard deduction is: no taxable-income override is needed.
test("a fully used pre-2018 NOL does not need the base standard deduction", () => {
const { r, p } = run({
general: {
filing_status: "single",
taxpayer_first_name: "Alex",
taxpayer_last_name: "Example",
taxpayer_dob: "1959-01-01",
},
nol_carryforward: {
nol_carryforwards: [{ year: 2017, nol_amount: 20_000, nol_type: "PRE2018" }],
},
});
assertEquals(r.diagnostics, []);
assertEquals(p.schedule1.line8a_nol_deduction, 20_000);
assertEquals(p.f1040.line11_agi, 100_000);
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface Fields {
line8n_section951a_inclusion?: number | null;
line8o_section951aa_inclusion?: number | null;
line8r_taxable_scholarships?: number | null;
line8t_nqdc_distribution?: number | null;
line8p_excess_business_loss?: number | null;
line8z_rtaa?: number | null;
line8z_taxable_grants?: number | null;
Expand Down Expand Up @@ -109,6 +110,7 @@ export const FIELD_MAP: ReadonlyArray<readonly [keyof Fields, string]> = [
["line8p_excess_business_loss", "ExcessBusinessLossAmt"],
["line8q_able_taxable_earnings", "TaxableABLEDistributionsAmt"],
["line8r_taxable_scholarships", "GrantsOrScholarshipsAmt"],
["line8t_nqdc_distribution", "NonqlfyDeferredCompensationAmt"],
["line8z_other", "OtherIncomeTotalAmt"],
["line9_total_other_income", "TotalOtherIncomeAmt"],
["line10_total_additional_income", "TotalAdditionalIncomeAmt"],
Expand Down
13 changes: 8 additions & 5 deletions forms/f1040/2025/mef/forms/income/business/schedule_c.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
computeTotalExpenses,
homeOfficeDeduction,
inputSchema as scheduleCInputSchema,
mealsDeductiblePct,
deductibleMeals,
projectScheduleCItems,
type ScheduleCItem,
wagesLessEmploymentCredits,
Expand Down Expand Up @@ -272,10 +272,13 @@ function buildScheduleC(
amount("SuppliesAmt", item.line_22_supplies),
amount("TaxesAndLicensesAmt", item.line_23_taxes_licenses),
amount("TravelAmt", item.line_24a_travel),
item.line_24b_meals === undefined ? "" : element(
"MealsAndEntertainmentAmt",
filing?.meals_deduction ?? item.line_24b_meals * mealsDeductiblePct(item),
),
item.line_24b_meals === undefined &&
item.line_24b_meals_dot_hours_of_service === undefined
? ""
: element(
"MealsAndEntertainmentAmt",
filing?.meals_deduction ?? deductibleMeals(item),
),
amount("UtilitiesAmt", item.line_25_utilities),
amount(
"WagesLessEmploymentCreditsAmt",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ const fields: ReadonlyArray<PdfFieldEntry> = [
domainKey: "line8r_taxable_scholarships",
pdfField: "topmostSubform[0].Page1[0].f1_30[0]",
},
// Line 8t: f1_31 is 8s, f1_32 is 8t, f1_33 is 8u (2025 Schedule 1).
{
kind: "text",
domainKey: "line8t_nqdc_distribution",
pdfField: "topmostSubform[0].Page1[0].f1_32[0]",
},
{
kind: "text",
domainKey: "form1099k_reported_error_or_loss",
Expand Down
5 changes: 2 additions & 3 deletions forms/f1040/2025/pdf/forms/income/business/schedule_c.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
computeTotalExpenses,
homeOfficeDeduction,
inputSchema,
mealsDeductiblePct,
deductibleMeals,
projectScheduleCItems,
type ScheduleCItem,
wagesLessEmploymentCredits,
Expand Down Expand Up @@ -308,8 +308,7 @@ function projectBusiness(
line5,
line6,
line7,
line_24b_meals: filing?.meals_deduction ??
(item.line_24b_meals ?? 0) * mealsDeductiblePct(item),
line_24b_meals: filing?.meals_deduction ?? deductibleMeals(item),
line_26_wages: wagesLessEmploymentCredits(item, wotcReduction),
line27b: line48,
line28,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export function assertReturnScheduleJoins(
"line8p_excess_business_loss",
"line8q_able_taxable_earnings",
"line8r_taxable_scholarships",
"line8t_nqdc_distribution",
) - line("line8d_foreign_earned_income_exclusion") +
schedule1ActivityNotForProfitTotal(schedule1) +
schedule1OtherIncomeTotal(schedule1);
Expand Down
16 changes: 16 additions & 0 deletions forms/f1040/2025/return-processing/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,23 @@ export function buildStartNode(entries: readonly InputNodeEntry[]): TaxNode {
fields: { owner_identity: ownerIdentity },
});
}
// Each filer's birth date sets the W-2 elective deferral catch-up.
const ownerBirthDates = [
[general?.taxpayer_ssn, general?.taxpayer_dob],
[general?.spouse_ssn, general?.spouse_dob],
].flatMap(([ssn, dob]) => {
const digits = String(ssn ?? "").replace(/\D/g, "");
return digits.length === 9 && typeof dob === "string"
? [{ ssn: digits, date_of_birth: dob }]
: [];
});
const ownerContext = (nodeType: string): Record<string, unknown> => {
if (nodeType === "w2" && ownerBirthDates.length > 0) {
return {
owner_birth_dates: ownerBirthDates,
...(ownerIdentity ? { owner_identity: ownerIdentity } : {}),
};
}
if (!ownerIdentity) return {};
if (
nodeType === "w2" || nodeType === "f4852" ||
Expand Down
42 changes: 42 additions & 0 deletions forms/f1040/e2e/adjustments/health/form7206_premiums.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test } from "bun:test";
import { assertEquals } from "@std/assert";
import { buildExecutionPlan } from "../../../../../core/runtime/planner.ts";
import { execute } from "../../../../../core/runtime/executor.ts";
import { registry } from "../../../2025/registry.ts";
import { FilingStatus } from "../../../nodes/types.ts";

const ctx = { taxYear: 2025, formType: "f1040" };
const plan = buildExecutionPlan(registry);

// Synthetic single return: Schedule C net profit 80,000; premiums 12,000 with
// no employer-plan-eligible months. 2025 Form 7206: line 4 = 80,000; SE tax
// 80,000 x 92.35% x 15.3% = 11,304 (rounded), deduction 5,652 (line 7);
// line 10 = 74,348 >= 12,000, so line 14 = 12,000 on Schedule 1 line 17.
test("Form 7206: premiums are limited by the return's own Schedule C earnings", () => {
const result = execute(plan, registry, {
general: {
filing_status: FilingStatus.Single,
taxpayer_first_name: "Test",
taxpayer_last_name: "Payer",
},
schedule_c: [{
business_reference: "design",
proprietor_recipient: "T",
line_a_principal_business: "DESIGN",
line_b_business_code: "541400",
line_f_accounting_method: "cash",
line_g_material_participation: true,
line_i_made_1099_payments: false,
line_1_gross_receipts: 80_000,
}],
self_employed_health_insurance: {
items: [{ premiums_paid: 12_000 }],
marketplace_ptc_premium_overlap: false,
},
}, ctx);

assertEquals(result.diagnostics, []);
assertEquals(result.pending.form7206?.line7, 5_652);
assertEquals(result.pending.form7206?.line14, 12_000);
assertEquals(result.pending.schedule1?.line17_se_health_insurance, 12_000);
});
25 changes: 25 additions & 0 deletions forms/f1040/e2e/credits/individual/form8880_joint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,28 @@ test("Form 8880: a joint return without a distribution ledger claims the credit"
assertEquals(result.pending.schedule3?.line4_retirement_savings_credit, 800);
assertEquals(form1040.line22_tax_after_credits, 853);
});

// An incomplete Form 8880 source (no student or dependent answers) must not
// stop Form 1040: the credit is left unclaimed with a warning.
test("Form 8880: missing eligibility answers leave the credit off with a warning", () => {
const result = execute(plan, registry, {
general: {
filing_status: FilingStatus.MFJ,
taxpayer_first_name: "Test",
taxpayer_last_name: "Payer",
taxpayer_ssn: "111-22-3333",
taxpayer_dob: "1985-01-01",
spouse_first_name: "Test",
spouse_last_name: "Spouse",
spouse_ssn: "111-22-4444",
spouse_dob: "1986-01-01",
},
w2: [w2("111-22-3333"), w2("111-22-4444")],
}, ctx);

assertEquals(result.diagnostics, []);
assertEquals(result.warnings?.length, 1);
const form1040 = result.pending.f1040 ?? {};
assertEquals(form1040.line11_agi, 48_000);
assertEquals(form1040.line22_tax_after_credits, 1_653);
});
36 changes: 36 additions & 0 deletions forms/f1040/e2e/income/w2_missing_medicare_wages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { test } from "bun:test";
import { assertEquals } from "@std/assert";
import { buildExecutionPlan } from "../../../../core/runtime/planner.ts";
import { execute } from "../../../../core/runtime/executor.ts";
import { registry } from "../../2025/registry.ts";
import { FilingStatus } from "../../nodes/types.ts";

const ctx = { taxYear: 2025, formType: "f1040" };
const plan = buildExecutionPlan(registry);

// Synthetic substitute W-2 built from a final pay stub: boxes 1, 2, 4 and 6
// but no box 3 or 5. Form 1040 lines 1a and 25a come from boxes 1 and 2 (2025
// Form 1040 instructions); only Form 8959 needs the Medicare wage base.
test("W-2 with box 6 but no box 5 keeps wages and withholding with a warning", () => {
const result = execute(plan, registry, {
general: {
filing_status: FilingStatus.Single,
taxpayer_first_name: "Test",
taxpayer_last_name: "Payer",
},
w2: [{
box1_wages: 113_698,
box2_fed_withheld: 20_306,
box4_ss_withheld: 7_429,
box6_medicare_withheld: 1_737,
box12_entries: [],
}],
}, ctx);

assertEquals(result.diagnostics, []);
assertEquals(result.warnings?.length, 1);
const form1040 = result.pending.f1040 ?? {};
assertEquals(form1040.line1z_total_wages, 113_698);
assertEquals(form1040.line25a_w2_withheld, 20_306);
assertEquals(form1040.line25d_total_withholding, 20_306);
});
40 changes: 40 additions & 0 deletions forms/f1040/e2e/income/w2_nqdc_distribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test } from "bun:test";
import { assertEquals } from "@std/assert";
import { buildExecutionPlan } from "../../../../core/runtime/planner.ts";
import { execute } from "../../../../core/runtime/executor.ts";
import { registry } from "../../2025/registry.ts";
import { FilingStatus } from "../../nodes/types.ts";

const ctx = { taxYear: 2025, formType: "f1040" };
const plan = buildExecutionPlan(registry);

// Synthetic W-2: box 1 60,000 includes a 10,000 nongovernmental section 457
// distribution also shown in box 11. 2025 Instructions for Form 1040, line
// 1a, and Schedule 1 line 8t: the distribution goes on line 8t, not line 1a;
// total income is unchanged.
test("W-2 box 11 NQDC distribution moves from line 1a to Schedule 1 line 8t", () => {
const result = execute(plan, registry, {
general: {
filing_status: FilingStatus.Single,
taxpayer_first_name: "Test",
taxpayer_last_name: "Payer",
},
w2: [{
box1_wages: 60_000,
box2_fed_withheld: 6_000,
box3_ss_wages: 50_000,
box4_ss_withheld: 3_100,
box5_medicare_wages: 50_000,
box6_medicare_withheld: 725,
box11_nonqual_plans: 10_000,
box11_nqdc_distribution: 10_000,
box12_entries: [],
}],
}, ctx);

assertEquals(result.diagnostics, []);
const form1040 = result.pending.f1040 ?? {};
assertEquals(form1040.line1z_total_wages, 50_000);
assertEquals(result.pending.schedule1?.line8t_nqdc_distribution, 10_000);
assertEquals(form1040.line9_total_income, 60_000);
});
Loading
Loading