最近我整理了一个多地区税率计算器项目。
第一版看起来很简单:输入金额,选择地区,套用规则,输出结果。
真正做成可以公开使用的产品后,问题很快冒了出来:
- 页面展示金额不一定等于实际计税基数;
- 官方数据、用户输入和比例估算的可信度不同;
- 预扣金额不等于最终预计税负;
- 不同身份会命中不同的计算门槛;
- 规则可能同时受到交易地、居住地和地方政策影响;
- 地方税并不总能安全地套一个固定比例;
- 分期现金流不能简单地用总额除以期数;
- 税率和门槛还会随年份变化。
这些问题与国内开发者常见的个税、房贷、保险、补贴和收益测算工具很像。公式本身通常不难,难的是规则来源、版本、优先级、异常输入和前端状态之间的关系。
如果把条件判断、接口请求和 UI 状态全部塞进 React 组件,页面也能跑,但后面几乎没法核对,更别说给规则升级。
这篇手记不讨论具体产品运营,只讲代码:如何用 TypeScript 收紧状态空间,用版本化配置管理规则,用纯函数组织计算管线,再用 Next.js 处理服务端数据、缓存和交互边界。
文中的类型名和函数名保留源码原样,业务术语则尽量抽象成可复用的工程问题。
一、技术栈
项目使用的技术都很常见:
| 技术 | 用途 |
|---|---|
| Next.js 14 App Router | 路由、Server Component、静态生成和 ISR |
| React 18 | 交互状态与派生结果 |
| TypeScript | 税务输入、结果和配置契约 |
| next-intl | 中英文路由与文案 |
| Redis | 实时数据、历史数据和汇率缓存 |
| Vitest | 领域函数和数学不变量测试 |
| Tailwind CSS | 数据表格与计算器界面 |
这套组合没有什么稀奇的,关键是每一层只做自己的事:
外部数据
→ 归一化与 Redis
→ Server Component
→ 纯计算引擎
→ Client Component
→ URL 保存可分享输入
计算引擎不依赖 React,国际化不进入公式,Redis 也不直接暴露给浏览器。
源码中的职责大致是这样:
app/[locale]/calculator/page.tsx
└─ 服务端聚合实时数据、汇率和页面初始值
components/TaxCalculator.tsx
└─ 收集输入、同步 URL、渲染结果
lib/tax.ts
└─ 纯计算、输入归一化、假设和警告
config/tax/*.ts
└─ 按税年保存规则、来源和版本
lib/tax.test.ts
└─ 验证业务规则、降级路径和数学不变量
页面组件不应该知道某个税档怎么算,配置文件也不应该操作 React state。把这条边界守住,后面的测试和升级才有意义。
二、先用 TypeScript 收紧状态空间
早期版本的输入可以简化成:
{
prizeAmount,
stateTaxRate,
payoutMode
}
它适合演示公式,但不足以表达真实业务。
当前版本把计算上下文定义成一份完整协议:
export type PowerballTaxInput = {
advertisedJackpot: number;
payoutMode: PayoutMode;
calculationMode: CalculationMode;
officialCashValue?: number;
customCashValue?: number;
estimatedCashRatio?: number;
filingStatus: FilingStatus;
ticketPurchaseState: USStateCode;
residencyState?: USStateCode;
locality?: Locality;
taxYear: number;
drawDate?: string;
};
几个容易混淆的字符串也没有继续放任为普通 string:
export type FilingStatus =
| "single"
| "married_jointly"
| "head_of_household";
export type PayoutMode = "cash" | "annuity";
export type CalculationMode = "live" | "custom";
export type CashValueSource =
| "official"
| "custom"
| "estimated_ratio"
| "annuity";
这不是为了展示 TypeScript 技巧,而是为了压缩非法状态。
例如 cashValueSource 只有四种可能。组件渲染来源标签时可以穷举处理,测试也能明确验证优先级。要是这里用普通字符串,拼错一个值,编译器完全管不着。
这里明确区分了:
- 实时计算还是自定义计算;
- 现金领取还是年金领取;
- 官方现金值还是用户输入值;
- 申报身份;
- 购票州和居住州;
- 地方区域;
- 税务年份。
结果类型也不只是一个 result:
export type PowerballTaxResult = {
advertisedJackpot: number;
grossPayout: number;
cashValueSource: CashValueSource;
cashValueRatio?: number;
federalWithholding: number;
estimatedAdditionalFederalTax: number;
estimatedFederalTaxTotal: number;
ticketStateTax: number;
residencyTaxAdjustment?: number;
localTax?: number;
totalEstimatedTax: number;
estimatedNetPayout: number;
effectiveTaxRate: number;
assumptions: string[];
warnings: string[];
taxDataVersion: string;
calculatedAt: string;
};
除了金额,它还返回:
cashValueSource:本次现金金额来自哪里;assumptions:使用了哪些估算假设;warnings:哪些规则没有建模或发生了回退;taxDataVersion:使用哪一版税务数据;calculatedAt:结果生成时间。
一个公开计算器不可能知道用户的全部报税信息。与其假装精确,不如把模型边界也放进结果。
三、计算管线比巨型函数更容易维护
calculatePowerballTax() 本身没有堆很多公式,而是串起三个阶段:
export function calculatePowerballTax(
input: PowerballTaxInput
): PowerballTaxResult {
if (input.payoutMode === "annuity") {
return calculatePowerballAnnuitySchedule(input).totals;
}
const context = buildCalculationContext(input);
const taxes = calculatePaymentTax(
context.grossPayout,
context.stateRate,
input.filingStatus,
context.config
);
return buildResult(
context,
taxes,
new Date().toISOString(),
input
);
}
三个阶段分别负责:
buildCalculationContext():归一化输入、选择配置、查找地区规则、收集假设和警告;calculatePaymentTax():只处理金额公式;buildResult():组装稳定的输出协议,并保留兼容字段。
这里的重点不是函数拆得越小越好,而是把副作用和规则决策赶出核心公式。
calculatePaymentTax() 不读取 URL,不访问 Redis,也不依赖当前语言。输入相同,结果就相同。这样的函数可以在页面、接口、批处理和测试中复用。
中间的 CalculationContext 则像一层领域适配器:
type CalculationContext = {
advertisedJackpot: number;
cashValueRatio?: number;
cashValueSource: CashValueSource;
config: TaxYearConfiguration;
grossPayout: number;
stateEntry?: StateTaxEntry;
stateRate: number;
assumptions: string[];
warnings: string[];
};
它把“原始输入”转换成“已经完成规则选择的计算上下文”。后面的公式不需要反复判断官方数据是否存在、税年是否回退、地区条目是否缺失。
四、结算金额不是单值,而是一条解析链
业务页面展示的可能是分期名义总额,不一定等于当前可结算的现金金额。
项目按下面的优先级解析结算金额:
用户自定义现金值
> 实时官方现金值
> 用户设置的估算比例
> 税年配置中的默认比例
核心实现位于 resolveCashValue():
const customCashValue =
normalizeMoney(input.customCashValue);
const officialCashValue =
normalizeMoney(input.officialCashValue);
if (customCashValue !== undefined) {
assumptions.push(
"Gross cash payout uses the user-supplied cash value."
);
return {
cashValueRatio:
advertisedJackpot > 0
? customCashValue / advertisedJackpot
: undefined,
cashValueSource: "custom",
grossPayout: customCashValue
};
}
if (officialCashValue !== undefined) {
assumptions.push(
"Gross cash payout uses the provided official cash value."
);
return {
cashValueRatio:
advertisedJackpot > 0
? officialCashValue / advertisedJackpot
: undefined,
cashValueSource: "official",
grossPayout: officialCashValue
};
}
前两种都不存在时,再使用比例估算:
const requestedRatio = input.estimatedCashRatio;
const ratio =
requestedRatio === undefined
? config.defaultEstimatedCashRatio
: Number.isFinite(requestedRatio) &&
requestedRatio > 0 &&
requestedRatio <= 1
? requestedRatio
: config.defaultEstimatedCashRatio;
assumptions.push(
`No official or custom cash value was available, ` +
`so gross cash payout is approximated at ` +
`${(ratio * 100).toFixed(1)}% of the advertised jackpot.`
);
这里没有把默认 60% 藏起来。结果会明确告诉用户:这次使用的是官方现金值,还是估算比例。
异常输入也不会被静默接受:
if (
customCashValue > advertisedJackpot &&
advertisedJackpot > 0
) {
warnings.push(
"The custom cash value exceeds the advertised jackpot. " +
"Verify the entered amounts."
);
}
这类警告不一定要阻断计算,但必须可见。
五、把税务规则做成版本化配置
税率和门槛会随年份变化,不适合散落在组件里。
项目定义了统一的税年配置:
export type TaxYearConfiguration = {
annuity: {
growthRate: number;
paymentCount: number;
};
defaultEstimatedCashRatio: number;
federal: {
additionalTopRateDelta: number;
topBracketRate: number;
topBracketThresholds:
Record<FilingStatus, number>;
withholdingRate: number;
};
sourceUrls: string[];
states: readonly StateTaxEntry[];
taxYear: number;
version: string;
};
2026 年配置如下:
export const TAX_CONFIG_2026 = {
annuity: {
growthRate: 0.05,
paymentCount: 30
},
defaultEstimatedCashRatio: 0.6,
federal: {
additionalTopRateDelta: 0.13,
topBracketRate: 0.37,
topBracketThresholds: {
head_of_household: 640_600,
married_jointly: 768_700,
single: 640_600
},
withholdingRate: 0.24
},
taxYear: 2026,
version: "2026.1"
};
申报身份不再只是 UI 文案,它会真正影响门槛:
const topBracketExposure = Math.max(
grossPayout -
config.federal
.topBracketThresholds[filingStatus],
0
);
const estimatedAdditionalFederalTax =
topBracketExposure *
config.federal.additionalTopRateDelta;
如果请求了没有配置的税年,系统回退到最新版本,但会留下警告:
const {config, usedFallback} =
getTaxConfiguration(input.taxYear);
if (usedFallback) {
warnings.push(
`Tax data for ${input.taxYear} is unavailable. ` +
`The ${config.taxYear} configuration ` +
`(${config.version}) was used as a fallback.`
);
}
回退不可怕,静默回退才可怕。
六、地区税率不只是 JSON 里的一个数字
把 50 个州的税率放进 JSON 只是基础操作。
当前模型为每条地区数据增加了元信息:
export type StateTaxEntry = {
stateCode: USStateCode;
stateName: string;
ticketStateRate: number;
residencyRuleNote: string;
localTaxNote: string;
localTaxSupport: "none" | "warning_only";
sourceUrls: string[];
verifiedAt: string;
taxYear: number;
version: string;
zhName: string;
};
一条税率除了数值,还需要回答:
- 属于哪个税年;
- 最后核验日期是什么;
- 数据来源在哪里;
- 是否存在未建模的地方税;
- 购票州和居住州不同时有哪些限制。
对于跨州居住规则,当前模型不会假装已经算完:
if (
input.residencyState &&
input.residencyState !==
input.ticketPurchaseState
) {
const residencyEntry = findStateEntry(
config,
input.residencyState
);
warnings.push(
residencyEntry
? `${residencyEntry.stateName} residency credits, ` +
`additional resident tax, and nonresident filing ` +
`rules are not modeled.`
: `Residency-state tax data for ` +
`${input.residencyState} is unavailable, so no ` +
`residency adjustment was calculated.`
);
}
对于纽约市和 Yonkers,项目也没有随便套一个固定比例:
if (input.locality && input.locality !== "none") {
warnings.push(
`${localityName} tax is not calculated because ` +
`the repository does not contain a verified flat rule ` +
`that is safe to apply to every winner.`
);
}
算不准的部分明确标记为“不计算”,比输出一个看起来很精确的错误数字更可靠。
七、预扣税与预计最终税负分开
计算函数将 24% 预扣与顶级税档补税估算分开:
const federalWithholding =
grossPayout * config.federal.withholdingRate;
const topBracketExposure = Math.max(
grossPayout -
config.federal
.topBracketThresholds[filingStatus],
0
);
const estimatedAdditionalFederalTax =
topBracketExposure *
config.federal.additionalTopRateDelta;
const estimatedFederalTaxTotal =
federalWithholding +
estimatedAdditionalFederalTax;
const ticketStateTax =
grossPayout * stateRate;
const totalEstimatedTax =
estimatedFederalTaxTotal + ticketStateTax;
它不是完整报税程序。模型没有覆盖扣除、抵免、信托、遗产规划和完整的跨州申报。
但它至少不会把“先预扣多少”和“最终预计承担多少”混成一个数字。
八、分期现金流要保留时间维度
当前项目中的分期方案由 30 笔逐年增长的付款组成:
- 第一笔在领取时支付;
- 后续每年一笔;
- 每笔比上一年增加 5%。
因此下面的写法是错误的:
const annualPayment = jackpot / 30;
真实付款序列是几何级数:
0.05 × 1.05^(n - 1)
--------------------------------
1.05^30 - 1
当前实现从税年配置中读取付款数量和增长率:
const {growthRate, paymentCount} =
context.config.annuity;
const denominator =
Math.pow(1 + growthRate, paymentCount) - 1;
for (
let year = 1;
year <= paymentCount;
year += 1
) {
const share =
(
growthRate *
Math.pow(1 + growthRate, year - 1)
) / denominator;
const grossPayout =
context.advertisedJackpot * share;
const taxes = calculatePaymentTax(
grossPayout,
context.stateRate,
annuityInput.filingStatus,
context.config
);
}
每一笔付款单独进入 calculatePaymentTax(),年度门槛也会逐年应用。
如果先把 30 年总额加起来,再当成一笔收入计税,时间维度就丢失了。
结果中还会明确加入限制:
context.warnings.push(
`The annuity estimate applies the ` +
`${context.config.taxYear} tax configuration ` +
`to every payment and does not discount future ` +
`payments to present value.`
);
也就是说,页面展示的是 30 年名义累计值,不是折现后的净现值。
九、React 状态分“源输入”和“派生结果”
当前计算器既有简单模式,也有高级模式。
高级输入包括:
const [calculationMode, setCalculationMode] =
useState<"live" | "custom">(...);
const [filingStatus, setFilingStatus] =
useState<FilingStatus>("single");
const [residencyState, setResidencyState] =
useState("same");
const [locality, setLocality] =
useState<Locality>("none");
const [customCashValue, setCustomCashValue] =
useState("");
const [
estimatedCashRatioPercent,
setEstimatedCashRatioPercent
] = useState("60");
这些都是用户源输入。
税务结果不再单独保存,而是通过 useMemo 推导:
const cashBreakdown = useMemo(
() =>
calculatePowerballTax({
advertisedJackpot: parsedAmount,
calculationMode,
customCashValue:
parsedCustomCashValue > 0
? parsedCustomCashValue
: undefined,
estimatedCashRatio,
filingStatus,
locality,
officialCashValue:
calculationMode === "live"
? currentCashValueUsd ?? undefined
: undefined,
payoutMode: "cash",
residencyState: resolvedResidencyState,
taxYear: 2026,
ticketPurchaseState:
selectedState.code as USStateCode
}),
[
calculationMode,
currentCashValueUsd,
estimatedCashRatio,
filingStatus,
locality,
parsedAmount,
parsedCustomCashValue,
resolvedResidencyState,
selectedState.code
]
);
estimatedNetPayout、totalEstimatedTax、警告列表和有效税率都不需要再建一份 state。
能够推导的数据一旦保存第二份,就会产生同步问题。
十、把 URL 当成可序列化输入
计算结果需要分享,因此 URL 保存核心条件:
/calculator?jackpot=1b&state=NY
首次加载通过 useSearchParams() 恢复。
用户输入时没有调用 router.replace(),而是使用 History API:
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const params = new URLSearchParams();
if (amount && parsedAmount > 0) {
params.set("jackpot", amount);
}
if (stateCode && stateCode !== propStateCode) {
params.set("state", stateCode);
}
const query = params.toString();
const next = query
? `${pathname}?${query}`
: pathname;
if (
`${window.location.pathname}` +
`${window.location.search}` !==
next
) {
window.history.replaceState(
null,
"",
next
);
}
}, [
amount,
parsedAmount,
stateCode,
pathname,
propStateCode
]);
这样地址栏始终携带当前条件,又不会在每次键入时触发新的 App Router 导航。
十一、Server Component 与 Client Component 分工
页面继续使用 Server Component 获取实时数据和汇率:
const [exchangeRate, powerballData] =
await Promise.all([
locale === "zh"
? getUsdCnyRate()
: Promise.resolve(null),
getPowerballData()
]);
交互计算器单独作为 Client Component,并由 Suspense 包裹:
<Suspense fallback={null}>
<TaxCalculator
currentCashValueUsd={currentCashValueUsd}
currentJackpotUsd={currentJackpotUsd}
exchangeRate={exchangeRate}
locale={locale}
states={states}
/>
</Suspense>
这个边界解决了两个问题:
- 首屏数据在服务端准备,浏览器不用再发一轮请求;
- 输入框、折叠面板和复制链接等交互留在客户端,不会把整页都变成 Client Component。
useSearchParams() 会让相关子树进入客户端渲染,因此这里显式使用 Suspense。这不是为了加一个加载动画,而是为了让 App Router 的静态预渲染边界保持清楚。
十二、缓存要按数据生命周期分层
项目中的缓存职责不同:
React cache
→ 单次服务端渲染内去重
Redis
→ 跨请求保存实时和历史数据
ISR
→ 控制页面输出更新时间
外部数据 TTL
→ 控制汇率等数据刷新频率
Redis 读取不属于 Next.js 的 fetch 缓存,因此使用 React cache():
async function loadPowerballData() {
const cached = await getCachedPowerballData();
if (cached) {
return cached;
}
return getSamplePowerballData();
}
export const getPowerballData =
cache(loadPowerballData);
一次读取多期详情时,则使用 Redis MGET:
mget: async <T>(keys: string[]) => {
if (keys.length === 0) return [];
const values = await redis.mGet(keys);
return values.map((value) =>
parseRedisValue<T>(value)
);
}
几十条数据的计算不贵,几十次网络往返才贵。
十三、测试规则、来源和降级
当前测试已经不只验证几个金额。
1. 输入来源优先级
const result = calculate({
advertisedJackpot: 500_000_000,
calculationMode: "live",
customCashValue: 275_000_000,
officialCashValue: 260_000_000
});
expect(result.grossPayout).toBe(275_000_000);
expect(result.cashValueSource).toBe("custom");
2. 不编造缺失的地方规则
it.each(["nyc", "yonkers"] as const)(
"returns a visible warning",
(locality) => {
const result = calculate({
locality,
residencyState: "NY",
ticketPurchaseState: "NY"
});
expect(result.localTax).toBeUndefined();
expect(result.warnings.join(" "))
.toContain("not calculated");
}
);
3. 不支持的税年必须可见回退
const result = calculate({
estimatedCashRatio: 4,
taxYear: 2030
});
expect(result.taxDataVersion).toBe("2026.1");
expect(result.warnings.join(" "))
.toContain("2030");
expect(result.warnings.join(" "))
.toContain("cash ratio");
4. 每条地区数据必须有来源
for (const entry of STATE_TAX_ENTRIES_2026) {
expect(entry.sourceUrls.length)
.toBeGreaterThan(0);
expect(entry.verifiedAt)
.toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(entry.taxYear).toBe(2026);
expect(entry.version).toBe("2026.1");
}
5. 分期计算的数学不变量
expect(rows).toHaveLength(
ANNUITY_PAYMENT_COUNT
);
const totalGross = rows.reduce(
(sum, row) => sum + row.grossPayout,
0
);
expect(totalGross).toBeCloseTo(jackpot, 0);
for (
let index = 1;
index < rows.length;
index += 1
) {
const ratio =
rows[index].grossPayout /
rows[index - 1].grossPayout;
expect(ratio).toBeCloseTo(
1 + ANNUITY_GROWTH_RATE,
6
);
}
测试的不只是“算出了多少钱”,还包括数据来源、模型边界和降级行为。
十四、这类计算器最容易踩的坑
| 问题 | 后果 | 处理方式 |
|---|---|---|
| 把展示总额当成结算金额 | 计税基数从源头就错了 | 保存金额来源,并建立明确的解析优先级 |
| 把预扣当成最终税率 | 用户会误解实际负担 | 分开返回预扣、追加估算和总税负 |
| 对不确定规则强行输出数字 | 得到看似精确的错误结果 | 返回结构化警告,明确未建模范围 |
| 配置只有数值,没有版本和来源 | 后续无法核验和升级 | 保存税年、版本、核验日期和来源链接 |
| 把多年现金流压成一笔收入 | 丢失年度门槛和增长关系 | 按期计算,并验证总额与增长率不变量 |
| 保存可以推导的 React 状态 | 多份状态迟早不同步 | 只保存源输入,通过纯函数推导结果 |
| 所有缓存共用一个生命周期 | 数据要么过期,要么频繁刷新 | 按请求、跨请求、页面和外部数据分层 |
结语
这个项目从一个“金额乘税率”的小工具,逐渐变成了一个带版本、来源、降级和可审计输出的小型领域模型。
真正困难的不是公式数量,而是下面这些问题:
- 输入是否表达了完整上下文;
- 每个金额是否有明确语义;
- 数据和规则是否有版本;
- 估算假设是否可见;
- 无法建模的部分是否诚实提示;
- 时间维度是否被保留;
- 多个页面是否共享同一套计算口径;
- 结果是否可以复现和测试。
这套思路并不限于税率计算器。
房贷、个税、保险、收益率和跨地区费用工具都可以采用类似结构:先建立版本化领域模型,再让前端负责收集输入和解释结果。
相关链接
本文中的税率和门槛用于讲解项目实现,不构成税务或法律建议。实际税负会受到申报身份、居住地、购票地、其他收入、抵扣项目和地方规则影响。