statetakehome-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/data/ca-tax-data.json +285 -0
- package/dist/data/state-tax-data.json +2431 -0
- package/dist/index.js +180 -0
- package/dist/lib/ca-data.js +18 -0
- package/dist/lib/state-data.js +22 -0
- package/dist/lib/tax-calc.js +510 -0
- package/package.json +58 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { FEDERAL, STATES, } from "./state-data.js";
|
|
2
|
+
import { CA_FEDERAL, PROVINCES, } from "./ca-data.js";
|
|
3
|
+
function applyBrackets(taxable, brackets) {
|
|
4
|
+
let tax = 0;
|
|
5
|
+
for (const b of brackets) {
|
|
6
|
+
if (taxable <= b.min)
|
|
7
|
+
break;
|
|
8
|
+
const top = b.max ?? Infinity;
|
|
9
|
+
const slice = Math.max(0, Math.min(taxable, top) - b.min);
|
|
10
|
+
tax += slice * b.rate;
|
|
11
|
+
}
|
|
12
|
+
return tax;
|
|
13
|
+
}
|
|
14
|
+
function findMarginalRate(taxable, brackets) {
|
|
15
|
+
for (const b of brackets) {
|
|
16
|
+
const top = b.max ?? Infinity;
|
|
17
|
+
if (taxable >= b.min && taxable < top)
|
|
18
|
+
return b.rate;
|
|
19
|
+
}
|
|
20
|
+
return brackets[brackets.length - 1]?.rate ?? 0;
|
|
21
|
+
}
|
|
22
|
+
function bracketsFor(state, status) {
|
|
23
|
+
if (state.tax_type !== "progressive" || !state.brackets)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (status === "married_filing_jointly") {
|
|
26
|
+
if (state.brackets.married_filing_jointly) {
|
|
27
|
+
return state.brackets.married_filing_jointly;
|
|
28
|
+
}
|
|
29
|
+
// MFJ brackets missing: most progressive states (incl. CA) double the
|
|
30
|
+
// single thresholds for joint filers. Falling back to raw single brackets
|
|
31
|
+
// would roughly double the joint tax bill.
|
|
32
|
+
return state.brackets.single?.map((b) => ({
|
|
33
|
+
rate: b.rate,
|
|
34
|
+
min: b.min * 2,
|
|
35
|
+
max: b.max === null ? null : b.max * 2,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
// single / MFS / HoH: single schedule is the standard state treatment
|
|
39
|
+
return state.brackets.single;
|
|
40
|
+
}
|
|
41
|
+
function stateStdDeduction(state, status) {
|
|
42
|
+
if (!state.standard_deduction)
|
|
43
|
+
return 0;
|
|
44
|
+
const explicit = state.standard_deduction[status];
|
|
45
|
+
if (explicit !== undefined)
|
|
46
|
+
return explicit;
|
|
47
|
+
const single = state.standard_deduction.single ?? 0;
|
|
48
|
+
// Same doubling convention as brackets when the MFJ amount is missing.
|
|
49
|
+
return status === "married_filing_jointly" ? single * 2 : single;
|
|
50
|
+
}
|
|
51
|
+
export function calculate(input) {
|
|
52
|
+
const gross = Math.max(0, Math.round(input.grossIncome || 0));
|
|
53
|
+
const status = input.filingStatus;
|
|
54
|
+
const stateAbbr = input.state.toUpperCase();
|
|
55
|
+
const state = STATES[stateAbbr];
|
|
56
|
+
const retirementContribution = Math.min(gross * Math.min(Math.max(input.pretax401kPct ?? 0, 0), 100) / 100, 23500 // 2026 employee deferral limit baseline
|
|
57
|
+
);
|
|
58
|
+
const pretaxHealth = Math.max(0, input.pretaxHealthAnnual ?? 0);
|
|
59
|
+
// FICA on full wages (FICA does NOT exempt 401k; DOES exempt some pretax health/HSA)
|
|
60
|
+
const ficaWages = Math.max(0, gross - pretaxHealth);
|
|
61
|
+
const socialSecurity = Math.min(ficaWages, FEDERAL.fica.social_security_wage_base) *
|
|
62
|
+
FEDERAL.fica.social_security_rate;
|
|
63
|
+
const medicare = ficaWages * FEDERAL.fica.medicare_rate;
|
|
64
|
+
const addlMedicareThreshold = status === "married_filing_jointly"
|
|
65
|
+
? FEDERAL.fica.additional_medicare_threshold_mfj
|
|
66
|
+
: FEDERAL.fica.additional_medicare_threshold_single;
|
|
67
|
+
const additionalMedicare = Math.max(0, ficaWages - addlMedicareThreshold) *
|
|
68
|
+
FEDERAL.fica.additional_medicare_rate;
|
|
69
|
+
// Federal taxable: gross - 401k - pretax health - federal std deduction
|
|
70
|
+
const fedStd = FEDERAL.standard_deduction[status] ?? FEDERAL.standard_deduction.single;
|
|
71
|
+
const federalTaxableIncome = Math.max(0, gross - retirementContribution - pretaxHealth - fedStd);
|
|
72
|
+
const fedBrackets = FEDERAL.tax_brackets[status] ?? FEDERAL.tax_brackets.single ?? [];
|
|
73
|
+
const federalTax = applyBrackets(federalTaxableIncome, fedBrackets);
|
|
74
|
+
// State tax
|
|
75
|
+
let stateTax = 0;
|
|
76
|
+
let stateTaxableIncome = 0;
|
|
77
|
+
const stateExtras = [];
|
|
78
|
+
if (state) {
|
|
79
|
+
if (state.tax_type === "none") {
|
|
80
|
+
stateTax = 0;
|
|
81
|
+
stateTaxableIncome = 0;
|
|
82
|
+
}
|
|
83
|
+
else if (state.tax_type === "flat") {
|
|
84
|
+
stateTaxableIncome = Math.max(0, gross - retirementContribution - pretaxHealth - stateStdDeduction(state, status));
|
|
85
|
+
stateTax = stateTaxableIncome * (state.rate ?? 0);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// progressive
|
|
89
|
+
stateTaxableIncome = Math.max(0, gross - retirementContribution - pretaxHealth - stateStdDeduction(state, status));
|
|
90
|
+
const sb = bracketsFor(state, status);
|
|
91
|
+
if (sb)
|
|
92
|
+
stateTax = applyBrackets(stateTaxableIncome, sb);
|
|
93
|
+
}
|
|
94
|
+
// State-specific extras
|
|
95
|
+
if (state.extra) {
|
|
96
|
+
if (state.extra.sdi_rate) {
|
|
97
|
+
// sdi_wage_base null = no cap (CA depuis 2024, SB 951). Sans ce ?? Infinity,
|
|
98
|
+
// la condition tombait à false et la SDI n'était jamais prélevée.
|
|
99
|
+
const sdiCap = state.extra.sdi_wage_base ?? Infinity;
|
|
100
|
+
const sdi = Math.min(gross, sdiCap) * state.extra.sdi_rate;
|
|
101
|
+
stateExtras.push({ name: `${state.abbr} SDI`, amount: sdi });
|
|
102
|
+
}
|
|
103
|
+
if (state.extra.mental_health_tax_rate &&
|
|
104
|
+
state.extra.mental_health_threshold &&
|
|
105
|
+
gross > state.extra.mental_health_threshold) {
|
|
106
|
+
const mht = (gross - state.extra.mental_health_threshold) *
|
|
107
|
+
state.extra.mental_health_tax_rate;
|
|
108
|
+
stateExtras.push({ name: "CA Mental Health Tax", amount: mht });
|
|
109
|
+
}
|
|
110
|
+
if (state.extra.transit_tax) {
|
|
111
|
+
stateExtras.push({
|
|
112
|
+
name: "OR Transit Tax",
|
|
113
|
+
amount: gross * state.extra.transit_tax,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const stateExtraTotal = stateExtras.reduce((s, x) => s + x.amount, 0);
|
|
119
|
+
const totalTax = federalTax + stateTax + socialSecurity + medicare + additionalMedicare + stateExtraTotal;
|
|
120
|
+
const takeHome = gross - retirementContribution - pretaxHealth - totalTax;
|
|
121
|
+
const marginalFed = findMarginalRate(federalTaxableIncome, fedBrackets);
|
|
122
|
+
const marginalState = (() => {
|
|
123
|
+
if (!state || state.tax_type === "none")
|
|
124
|
+
return 0;
|
|
125
|
+
if (state.tax_type === "flat")
|
|
126
|
+
return state.rate ?? 0;
|
|
127
|
+
const sb = bracketsFor(state, status);
|
|
128
|
+
return sb ? findMarginalRate(stateTaxableIncome, sb) : 0;
|
|
129
|
+
})();
|
|
130
|
+
const marginalRate = marginalFed +
|
|
131
|
+
marginalState +
|
|
132
|
+
FEDERAL.fica.social_security_rate +
|
|
133
|
+
FEDERAL.fica.medicare_rate;
|
|
134
|
+
const perPaycheck = {
|
|
135
|
+
weekly: takeHome / 52,
|
|
136
|
+
biweekly: takeHome / 26,
|
|
137
|
+
semimonthly: takeHome / 24,
|
|
138
|
+
monthly: takeHome / 12,
|
|
139
|
+
};
|
|
140
|
+
const row = (label, annual, kind) => ({
|
|
141
|
+
label,
|
|
142
|
+
annual,
|
|
143
|
+
monthly: annual / 12,
|
|
144
|
+
perPaycheck: annual / 26,
|
|
145
|
+
pctOfGross: gross > 0 ? annual / gross : 0,
|
|
146
|
+
kind,
|
|
147
|
+
});
|
|
148
|
+
const breakdown = [
|
|
149
|
+
row("Gross Income", gross, "income"),
|
|
150
|
+
...(retirementContribution > 0
|
|
151
|
+
? [row("401(k) (pre-tax)", -retirementContribution, "deduction")]
|
|
152
|
+
: []),
|
|
153
|
+
...(pretaxHealth > 0
|
|
154
|
+
? [row("Pre-tax Health Insurance", -pretaxHealth, "deduction")]
|
|
155
|
+
: []),
|
|
156
|
+
row("Federal Income Tax", -federalTax, "tax"),
|
|
157
|
+
...(state && state.tax_type !== "none"
|
|
158
|
+
? [row(`${state.name} Income Tax`, -stateTax, "tax")]
|
|
159
|
+
: []),
|
|
160
|
+
row("Social Security (6.2%)", -socialSecurity, "tax"),
|
|
161
|
+
row("Medicare (1.45%)", -medicare, "tax"),
|
|
162
|
+
...(additionalMedicare > 0
|
|
163
|
+
? [row("Add'l Medicare (0.9%)", -additionalMedicare, "tax")]
|
|
164
|
+
: []),
|
|
165
|
+
...stateExtras.map((x) => row(x.name, -x.amount, "tax")),
|
|
166
|
+
row("Take-Home Pay", takeHome, "total"),
|
|
167
|
+
];
|
|
168
|
+
return {
|
|
169
|
+
gross,
|
|
170
|
+
retirementContribution,
|
|
171
|
+
pretaxHealth,
|
|
172
|
+
federalTaxableIncome,
|
|
173
|
+
federalTax,
|
|
174
|
+
stateTaxableIncome,
|
|
175
|
+
stateTax,
|
|
176
|
+
socialSecurity,
|
|
177
|
+
medicare,
|
|
178
|
+
additionalMedicare,
|
|
179
|
+
stateExtras,
|
|
180
|
+
totalTax,
|
|
181
|
+
takeHome,
|
|
182
|
+
effectiveRate: gross > 0 ? totalTax / gross : 0,
|
|
183
|
+
marginalRate,
|
|
184
|
+
perPaycheck,
|
|
185
|
+
breakdown,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// Useful sanity helper for tests
|
|
189
|
+
export function quickTakeHome(gross, stateAbbr) {
|
|
190
|
+
return calculate({
|
|
191
|
+
grossIncome: gross,
|
|
192
|
+
state: stateAbbr,
|
|
193
|
+
filingStatus: "single",
|
|
194
|
+
}).takeHome;
|
|
195
|
+
}
|
|
196
|
+
export function selfEmploymentTax(input) {
|
|
197
|
+
const netProfit = Math.max(0, Math.round(input.netProfit || 0));
|
|
198
|
+
const otherIncome = Math.max(0, input.otherIncome ?? 0);
|
|
199
|
+
const status = input.filingStatus;
|
|
200
|
+
const ssRate = FEDERAL.fica.social_security_rate * 2; // 12.4%
|
|
201
|
+
const medRate = FEDERAL.fica.medicare_rate * 2; // 2.9%
|
|
202
|
+
const wageBase = FEDERAL.fica.social_security_wage_base;
|
|
203
|
+
const seBase = netProfit * 0.9235;
|
|
204
|
+
// SS portion: 12.4% on the SE base, but the wage base is shared with any W-2
|
|
205
|
+
// wages already taxed for Social Security (otherIncome treated as such here).
|
|
206
|
+
const ssRoom = Math.max(0, wageBase - otherIncome);
|
|
207
|
+
const socialSecurityPortion = Math.min(seBase, ssRoom) * ssRate;
|
|
208
|
+
const medicarePortion = seBase * medRate;
|
|
209
|
+
const seTax = socialSecurityPortion + medicarePortion;
|
|
210
|
+
const seTaxDeduction = seTax * 0.5;
|
|
211
|
+
// Additional Medicare 0.9% above the threshold (on combined Medicare wages).
|
|
212
|
+
const addlThreshold = status === "married_filing_jointly"
|
|
213
|
+
? FEDERAL.fica.additional_medicare_threshold_mfj
|
|
214
|
+
: FEDERAL.fica.additional_medicare_threshold_single;
|
|
215
|
+
const additionalMedicare = Math.max(0, seBase + otherIncome - addlThreshold) * FEDERAL.fica.additional_medicare_rate;
|
|
216
|
+
// Federal income tax on (net profit + other income − half SE tax − std deduction).
|
|
217
|
+
const fedStd = FEDERAL.standard_deduction[status] ?? FEDERAL.standard_deduction.single;
|
|
218
|
+
const federalTaxableIncome = Math.max(0, netProfit + otherIncome - seTaxDeduction - fedStd);
|
|
219
|
+
const fedBrackets = FEDERAL.tax_brackets[status] ?? FEDERAL.tax_brackets.single ?? [];
|
|
220
|
+
const federalIncomeTax = applyBrackets(federalTaxableIncome, fedBrackets);
|
|
221
|
+
// Optional state income tax (approx: starts from net profit + other income − half SE tax).
|
|
222
|
+
let stateTax = 0;
|
|
223
|
+
const stateAbbr = (input.state ?? "").toUpperCase();
|
|
224
|
+
const state = STATES[stateAbbr];
|
|
225
|
+
if (state && state.tax_type !== "none") {
|
|
226
|
+
const stateTaxable = Math.max(0, netProfit + otherIncome - seTaxDeduction - stateStdDeduction(state, status));
|
|
227
|
+
if (state.tax_type === "flat") {
|
|
228
|
+
stateTax = stateTaxable * (state.rate ?? 0);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const sb = bracketsFor(state, status);
|
|
232
|
+
if (sb)
|
|
233
|
+
stateTax = applyBrackets(stateTaxable, sb);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const totalTax = seTax + additionalMedicare + federalIncomeTax + stateTax;
|
|
237
|
+
const netAfterTax = netProfit + otherIncome - totalTax;
|
|
238
|
+
const marginalFed = findMarginalRate(federalTaxableIncome, fedBrackets);
|
|
239
|
+
const marginalState = (() => {
|
|
240
|
+
if (!state || state.tax_type === "none")
|
|
241
|
+
return 0;
|
|
242
|
+
if (state.tax_type === "flat")
|
|
243
|
+
return state.rate ?? 0;
|
|
244
|
+
const sb = bracketsFor(state, status);
|
|
245
|
+
return sb ? findMarginalRate(federalTaxableIncome, sb) : 0;
|
|
246
|
+
})();
|
|
247
|
+
// SE marginal on the next dollar: 15.3% under the wage base, else 2.9%, on 92.35%.
|
|
248
|
+
const seMarginal = (seBase < ssRoom ? ssRate + medRate : medRate) * 0.9235;
|
|
249
|
+
const marginalRate = marginalFed + marginalState + seMarginal;
|
|
250
|
+
return {
|
|
251
|
+
netProfit,
|
|
252
|
+
seBase,
|
|
253
|
+
socialSecurityPortion,
|
|
254
|
+
medicarePortion,
|
|
255
|
+
additionalMedicare,
|
|
256
|
+
seTax,
|
|
257
|
+
seTaxDeduction,
|
|
258
|
+
federalTaxableIncome,
|
|
259
|
+
federalIncomeTax,
|
|
260
|
+
stateTax,
|
|
261
|
+
totalTax,
|
|
262
|
+
netAfterTax,
|
|
263
|
+
setAsidePct: netProfit > 0 ? totalTax / netProfit : 0,
|
|
264
|
+
effectiveRate: netProfit + otherIncome > 0 ? totalTax / (netProfit + otherIncome) : 0,
|
|
265
|
+
marginalRate,
|
|
266
|
+
quarterlyEstimate: totalTax / 4,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// CAPITAL GAINS ENGINE (2026)
|
|
271
|
+
// Long-term: stacked 0% / 15% / 20% by taxable income (Rev. Proc. 2025-32,
|
|
272
|
+
// verified vs Tax Foundation + Schwab, 2026-06-20). Short-term: ordinary rates.
|
|
273
|
+
// Plus the 3.8% Net Investment Income Tax (NIIT) above the MAGI threshold
|
|
274
|
+
// ($200k single / $250k MFJ, fixed since 2013). State capital gains usually
|
|
275
|
+
// taxed as ordinary income (some states differ — noted on-page).
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Upper bound of the 0% and 15% long-term bands, by taxable income (2026).
|
|
278
|
+
const LTCG_2026 = {
|
|
279
|
+
single: { zeroTop: 49450, fifteenTop: 545500 },
|
|
280
|
+
married_filing_jointly: { zeroTop: 98900, fifteenTop: 613700 },
|
|
281
|
+
head_of_household: { zeroTop: 66200, fifteenTop: 579600 },
|
|
282
|
+
married_filing_separately: { zeroTop: 49450, fifteenTop: 306850 },
|
|
283
|
+
};
|
|
284
|
+
const NIIT_RATE = 0.038;
|
|
285
|
+
const NIIT_THRESHOLD = {
|
|
286
|
+
single: 200000,
|
|
287
|
+
married_filing_jointly: 250000,
|
|
288
|
+
head_of_household: 200000,
|
|
289
|
+
married_filing_separately: 125000,
|
|
290
|
+
};
|
|
291
|
+
export function capitalGains(input) {
|
|
292
|
+
const gain = Math.max(0, Math.round(input.gain || 0));
|
|
293
|
+
const exclusion = Math.min(gain, Math.max(0, input.exclusion ?? 0));
|
|
294
|
+
const taxableGain = Math.max(0, gain - exclusion);
|
|
295
|
+
const inc = Math.max(0, input.ordinaryTaxableIncome || 0);
|
|
296
|
+
const status = input.filingStatus;
|
|
297
|
+
const fedBrackets = FEDERAL.tax_brackets[status] ?? FEDERAL.tax_brackets.single ?? [];
|
|
298
|
+
let federalTax = 0;
|
|
299
|
+
let ltcgRate = 0;
|
|
300
|
+
if (input.term === "short") {
|
|
301
|
+
// Short-term = ordinary rates, stacked on top of other income.
|
|
302
|
+
federalTax =
|
|
303
|
+
applyBrackets(inc + taxableGain, fedBrackets) - applyBrackets(inc, fedBrackets);
|
|
304
|
+
ltcgRate = findMarginalRate(inc + taxableGain, fedBrackets);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
const b = LTCG_2026[status] ?? LTCG_2026.single;
|
|
308
|
+
const top = inc + taxableGain;
|
|
309
|
+
const at0 = Math.max(0, Math.min(top, b.zeroTop) - inc);
|
|
310
|
+
const at15 = Math.max(0, Math.min(top, b.fifteenTop) - Math.max(inc, b.zeroTop));
|
|
311
|
+
const at20 = Math.max(0, top - Math.max(inc, b.fifteenTop));
|
|
312
|
+
federalTax = at15 * 0.15 + at20 * 0.2;
|
|
313
|
+
ltcgRate = at20 > 0 ? 0.2 : at15 > 0 ? 0.15 : 0;
|
|
314
|
+
}
|
|
315
|
+
// NIIT: 3.8% on the lesser of the taxable gain or (MAGI − threshold).
|
|
316
|
+
const magi = inc + taxableGain;
|
|
317
|
+
const niitThreshold = NIIT_THRESHOLD[status] ?? NIIT_THRESHOLD.single;
|
|
318
|
+
const niit = NIIT_RATE * Math.max(0, Math.min(taxableGain, magi - niitThreshold));
|
|
319
|
+
// State: treated as ordinary income (the common case), stacked.
|
|
320
|
+
let stateTax = 0;
|
|
321
|
+
const stateAbbr = (input.state ?? "").toUpperCase();
|
|
322
|
+
const state = STATES[stateAbbr];
|
|
323
|
+
if (state && state.tax_type !== "none" && taxableGain > 0) {
|
|
324
|
+
if (state.tax_type === "flat") {
|
|
325
|
+
stateTax = taxableGain * (state.rate ?? 0);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const sb = bracketsFor(state, status);
|
|
329
|
+
if (sb)
|
|
330
|
+
stateTax = applyBrackets(inc + taxableGain, sb) - applyBrackets(inc, sb);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const totalTax = federalTax + niit + stateTax;
|
|
334
|
+
return {
|
|
335
|
+
gain,
|
|
336
|
+
exclusion,
|
|
337
|
+
taxableGain,
|
|
338
|
+
term: input.term,
|
|
339
|
+
federalTax,
|
|
340
|
+
niit,
|
|
341
|
+
stateTax,
|
|
342
|
+
totalTax,
|
|
343
|
+
netProceeds: taxableGain - totalTax,
|
|
344
|
+
effectiveRate: taxableGain > 0 ? totalTax / taxableGain : 0,
|
|
345
|
+
ltcgRate,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// Basic Personal Amount with optional high-income phase-out (federal + Yukon).
|
|
349
|
+
function caBpaAmount(income, p) {
|
|
350
|
+
if (income <= p.phaseout_start)
|
|
351
|
+
return p.max;
|
|
352
|
+
if (income >= p.phaseout_end)
|
|
353
|
+
return p.base;
|
|
354
|
+
return p.max - ((p.max - p.base) * (income - p.phaseout_start)) / (p.phaseout_end - p.phaseout_start);
|
|
355
|
+
}
|
|
356
|
+
// CPP/QPP contribution: base on (pensionable earnings − exemption), capped,
|
|
357
|
+
// plus the second additional contribution (CPP2/QPP2) on the upper band.
|
|
358
|
+
function caPensionContribution(gross, plan, plan2) {
|
|
359
|
+
const pensionable = Math.min(gross, plan.max_pensionable_earnings);
|
|
360
|
+
const contributory = Math.max(0, pensionable - plan.basic_exemption);
|
|
361
|
+
const base = Math.min(contributory * plan.rate, plan.max_contribution);
|
|
362
|
+
const secondBand = Math.max(0, Math.min(gross, plan2.upper) - plan2.lower);
|
|
363
|
+
const second = Math.min(secondBand * plan2.rate, plan2.max_contribution);
|
|
364
|
+
return { base, second };
|
|
365
|
+
}
|
|
366
|
+
// Ontario Health Premium (2026 schedule — unchanged for years), by taxable income.
|
|
367
|
+
function ontarioHealthPremium(ti) {
|
|
368
|
+
if (ti <= 20000)
|
|
369
|
+
return 0;
|
|
370
|
+
if (ti <= 36000)
|
|
371
|
+
return Math.min((ti - 20000) * 0.06, 300);
|
|
372
|
+
if (ti <= 48000)
|
|
373
|
+
return Math.min(300 + (ti - 36000) * 0.06, 450);
|
|
374
|
+
if (ti <= 72000)
|
|
375
|
+
return Math.min(450 + (ti - 48000) * 0.25, 600);
|
|
376
|
+
if (ti <= 200000)
|
|
377
|
+
return Math.min(600 + (ti - 72000) * 0.25, 750);
|
|
378
|
+
return Math.min(750 + (ti - 200000) * 0.25, 900);
|
|
379
|
+
}
|
|
380
|
+
export function calculateCanada(input) {
|
|
381
|
+
const gross = Math.max(0, Math.round(input.grossIncome || 0));
|
|
382
|
+
const provAbbr = input.province.toUpperCase();
|
|
383
|
+
const prov = PROVINCES[provAbbr];
|
|
384
|
+
const F = CA_FEDERAL;
|
|
385
|
+
const rrsp = Math.max(0, input.rrspContribution ?? 0);
|
|
386
|
+
const pretaxHealth = Math.max(0, input.pretaxHealthAnnual ?? 0);
|
|
387
|
+
// Taxable income (Canada): gross − RRSP − other pre-tax. No standard deduction;
|
|
388
|
+
// the Basic Personal Amount is applied as a non-refundable tax credit.
|
|
389
|
+
const taxableIncome = Math.max(0, gross - rrsp - pretaxHealth);
|
|
390
|
+
// --- Federal income tax (BPA is a credit at the lowest rate) ---
|
|
391
|
+
const fedBpa = caBpaAmount(taxableIncome, F.basic_personal_amount);
|
|
392
|
+
let federalTax = Math.max(0, applyBrackets(taxableIncome, F.brackets) - fedBpa * F.bpa_credit_rate);
|
|
393
|
+
// Quebec abatement: 16.5% of net federal tax for QC residents.
|
|
394
|
+
if (prov && prov.abatement) {
|
|
395
|
+
federalTax = federalTax * (1 - F.quebec_abatement_rate);
|
|
396
|
+
}
|
|
397
|
+
// --- Provincial income tax ---
|
|
398
|
+
let provincialTax = 0;
|
|
399
|
+
let basicProvTax = 0;
|
|
400
|
+
const provExtras = [];
|
|
401
|
+
if (prov) {
|
|
402
|
+
const provBpa = prov.bpa_phaseout
|
|
403
|
+
? caBpaAmount(taxableIncome, prov.bpa_phaseout)
|
|
404
|
+
: prov.basic_personal_amount;
|
|
405
|
+
basicProvTax = Math.max(0, applyBrackets(taxableIncome, prov.brackets) - provBpa * prov.bpa_credit_rate);
|
|
406
|
+
let provTax = basicProvTax;
|
|
407
|
+
// Provincial surtax (Ontario) — on basic provincial tax above thresholds.
|
|
408
|
+
if (prov.surtax) {
|
|
409
|
+
for (const s of prov.surtax)
|
|
410
|
+
provTax += Math.max(0, basicProvTax - s.threshold) * s.rate;
|
|
411
|
+
}
|
|
412
|
+
provincialTax = provTax;
|
|
413
|
+
// Ontario Health Premium (separate levy by taxable income).
|
|
414
|
+
if (prov.health_premium) {
|
|
415
|
+
const ohp = ontarioHealthPremium(taxableIncome);
|
|
416
|
+
if (ohp > 0)
|
|
417
|
+
provExtras.push({ name: "Ontario Health Premium", amount: ohp });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// --- CPP/QPP + EI/QPIP (on gross employment income) ---
|
|
421
|
+
const usesQpp = !!prov && prov.pension === "QPP";
|
|
422
|
+
const pensionPlan = usesQpp ? F.qpp : F.cpp;
|
|
423
|
+
const pensionPlan2 = usesQpp ? F.qpp2 : F.cpp2;
|
|
424
|
+
const { base: pension1, second: pension2 } = caPensionContribution(gross, pensionPlan, pensionPlan2);
|
|
425
|
+
const pensionName = usesQpp ? "QPP (RRQ)" : "CPP";
|
|
426
|
+
const pension2Name = usesQpp ? "QPP2 (RRQ)" : "CPP2";
|
|
427
|
+
const eiPlan = prov && prov.ei === "quebec" ? F.ei_quebec : F.ei_federal;
|
|
428
|
+
const ei = Math.min(gross, eiPlan.max_insurable_earnings) * eiPlan.rate;
|
|
429
|
+
const eiName = prov && prov.ei === "quebec" ? "EI (Quebec rate)" : "Employment Insurance";
|
|
430
|
+
let qpip = 0;
|
|
431
|
+
if (prov && prov.qpip) {
|
|
432
|
+
qpip = Math.min(gross, F.qpip.max_insurable_earnings) * F.qpip.rate;
|
|
433
|
+
}
|
|
434
|
+
// Map Canadian payroll contributions into stateExtras (so the widget renders them).
|
|
435
|
+
const payroll = [{ name: pensionName, amount: pension1 }];
|
|
436
|
+
if (pension2 > 0)
|
|
437
|
+
payroll.push({ name: pension2Name, amount: pension2 });
|
|
438
|
+
payroll.push({ name: eiName, amount: ei });
|
|
439
|
+
if (qpip > 0)
|
|
440
|
+
payroll.push({ name: "QPIP (RQAP)", amount: qpip });
|
|
441
|
+
const stateExtras = [...payroll, ...provExtras];
|
|
442
|
+
const stateExtraTotal = stateExtras.reduce((s, x) => s + x.amount, 0);
|
|
443
|
+
const totalTax = federalTax + provincialTax + stateExtraTotal;
|
|
444
|
+
const takeHome = gross - rrsp - pretaxHealth - totalTax;
|
|
445
|
+
// --- Marginal rate (income tax only, matching published combined marginal tables) ---
|
|
446
|
+
let marginalFed = findMarginalRate(taxableIncome, F.brackets);
|
|
447
|
+
if (prov && prov.abatement)
|
|
448
|
+
marginalFed *= 1 - F.quebec_abatement_rate;
|
|
449
|
+
let marginalProv = 0;
|
|
450
|
+
if (prov) {
|
|
451
|
+
marginalProv = findMarginalRate(taxableIncome, prov.brackets);
|
|
452
|
+
if (prov.surtax) {
|
|
453
|
+
let factor = 1;
|
|
454
|
+
for (const s of prov.surtax)
|
|
455
|
+
if (basicProvTax > s.threshold)
|
|
456
|
+
factor += s.rate;
|
|
457
|
+
marginalProv *= factor;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const marginalRate = marginalFed + marginalProv;
|
|
461
|
+
const perPaycheck = {
|
|
462
|
+
weekly: takeHome / 52,
|
|
463
|
+
biweekly: takeHome / 26,
|
|
464
|
+
semimonthly: takeHome / 24,
|
|
465
|
+
monthly: takeHome / 12,
|
|
466
|
+
};
|
|
467
|
+
const row = (label, annual, kind) => ({
|
|
468
|
+
label,
|
|
469
|
+
annual,
|
|
470
|
+
monthly: annual / 12,
|
|
471
|
+
perPaycheck: annual / 26,
|
|
472
|
+
pctOfGross: gross > 0 ? annual / gross : 0,
|
|
473
|
+
kind,
|
|
474
|
+
});
|
|
475
|
+
const breakdown = [
|
|
476
|
+
row("Gross Income", gross, "income"),
|
|
477
|
+
...(rrsp > 0 ? [row("RRSP/Pension (pre-tax)", -rrsp, "deduction")] : []),
|
|
478
|
+
...(pretaxHealth > 0 ? [row("Pre-tax deductions", -pretaxHealth, "deduction")] : []),
|
|
479
|
+
row("Federal Income Tax", -federalTax, "tax"),
|
|
480
|
+
...(prov ? [row(`${prov.name} Provincial Tax`, -provincialTax, "tax")] : []),
|
|
481
|
+
row(pensionName, -pension1, "tax"),
|
|
482
|
+
...(pension2 > 0 ? [row(pension2Name, -pension2, "tax")] : []),
|
|
483
|
+
row(eiName, -ei, "tax"),
|
|
484
|
+
...(qpip > 0 ? [row("QPIP (RQAP)", -qpip, "tax")] : []),
|
|
485
|
+
...provExtras.map((x) => row(x.name, -x.amount, "tax")),
|
|
486
|
+
row("Take-Home Pay", takeHome, "total"),
|
|
487
|
+
];
|
|
488
|
+
return {
|
|
489
|
+
gross,
|
|
490
|
+
retirementContribution: rrsp,
|
|
491
|
+
pretaxHealth,
|
|
492
|
+
federalTaxableIncome: taxableIncome,
|
|
493
|
+
federalTax,
|
|
494
|
+
stateTaxableIncome: taxableIncome,
|
|
495
|
+
stateTax: provincialTax,
|
|
496
|
+
socialSecurity: 0,
|
|
497
|
+
medicare: 0,
|
|
498
|
+
additionalMedicare: 0,
|
|
499
|
+
stateExtras,
|
|
500
|
+
totalTax,
|
|
501
|
+
takeHome,
|
|
502
|
+
effectiveRate: gross > 0 ? totalTax / gross : 0,
|
|
503
|
+
marginalRate,
|
|
504
|
+
perPaycheck,
|
|
505
|
+
breakdown,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
export function quickTakeHomeCanada(gross, province) {
|
|
509
|
+
return calculateCanada({ grossIncome: gross, province }).takeHome;
|
|
510
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "statetakehome-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Take-home pay MCP server for all 50 US states + DC — 2026 federal & state brackets, FICA, and the new OBBBA tips/overtime deductions, straight from statetakehome.com's calculation engine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"statetakehome-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc && node --eval \"const fs=require('fs');fs.cpSync('src/data','dist/data',{recursive:true})\"",
|
|
18
|
+
"test": "node test/smoke.mjs",
|
|
19
|
+
"prepublishOnly": "npm run build && npm test"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"take-home-pay",
|
|
28
|
+
"paycheck",
|
|
29
|
+
"salary-calculator",
|
|
30
|
+
"payroll",
|
|
31
|
+
"state-taxes",
|
|
32
|
+
"income-tax",
|
|
33
|
+
"fica",
|
|
34
|
+
"self-employment-tax",
|
|
35
|
+
"capital-gains-tax",
|
|
36
|
+
"obbba",
|
|
37
|
+
"paycheck-calculator",
|
|
38
|
+
"tax-calculator"
|
|
39
|
+
],
|
|
40
|
+
"author": "tresor4k",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/tresor4k/statetakehome-mcp.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://statetakehome.com",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/tresor4k/statetakehome-mcp/issues"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
52
|
+
"zod": "^3.23"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "^5.5",
|
|
56
|
+
"@types/node": "^20.14.0"
|
|
57
|
+
}
|
|
58
|
+
}
|