最近我整理了一个多地区税率计算器项目。
第一版看起来很简单:输入金额,选择地区,套用规则,输出结果。
真正做成可以公开使用的产品后,问题很快冒了出来:
- 页面展示金额不一定等于实际计税基数;
- 官方数据、用户输入和比例估算的可信度不同;
- 预扣金额不等于最终预计税负;
- 不同身份会命中不同的计算门槛;
- 规则可能同时受到交易地、居住地和地方政策影响;
- 地方税并不总能安全地套一个固定比例;
- 分期现金流不能简单地用总额除以期数;
- 税率和门槛还会随年份变化。
这些问题与国内开发者常见的个税、房贷、保险、补贴和收益测算工具很像。公式本身通常不难,难的是规则来源、版本、优先级、异常输入和前端状态之间的关系。
如果把条件判断、接口请求和 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缓存,因此使用 Reactcache():
async function loadPowerballData() { const cached = await getCachedPowerballData(); if (cached) { return cached; } return getSamplePowerballData(); } export const getPowerballData = cache(loadPowerballData);一次读取多期详情时,则使用 RedisMGET:
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 状态 | 多份状态迟早不同步 | 只保存源输入,通过纯函数推导结果 |
| 所有缓存共用一个生命周期 | 数据要么过期,要么频繁刷新 | 按请求、跨请求、页面和外部数据分层 |
结语
这个项目从一个“金额乘税率”的小工具,逐渐变成了一个带版本、来源、降级和可审计输出的小型领域模型。
真正困难的不是公式数量,而是下面这些问题:
- 输入是否表达了完整上下文;
- 每个金额是否有明确语义;
- 数据和规则是否有版本;
- 估算假设是否可见;
- 无法建模的部分是否诚实提示;
- 时间维度是否被保留;
- 多个页面是否共享同一套计算口径;
- 结果是否可以复现和测试。
这套思路并不限于税率计算器。
房贷、个税、保险、收益率和跨地区费用工具都可以采用类似结构:先建立版本化领域模型,再让前端负责收集输入和解释结果。