用 TypeScript + decimal.js 实现房贷和个税计算器:那些「算钱」时会踩的坑
做工具站的时候,我把房贷、个税这类计算器的核心逻辑抽成了一个纯 TypeScript 的共享库(三端复用,零平台 API)。看起来都是小学算术,真正写起来才发现,「算钱」这件事远不止套公式——浮点精度、逐期舍入漂移、税率临界点,每一个都能让结果差出几分钱甚至几千块。
这篇就拿其中两个计算器(房贷、个税)说说实现细节和踩过的坑。文末有在线版可以直接试。
一、第一条铁律:算钱不要用 JS 浮点
老生常谈,但必须先摆出来:
0.1 + 0.2 // 0.30000000000000004
(1.005).toFixed(2) // "1.00" ← 银行家舍入 + 二进制误差,本该是 1.01
金额一旦走原生 number,逐期累加几百次之后误差会肉眼可见。所以我用 decimal.js,并约定一条规则:中间过程全程高精度 Decimal,只在输出边界四舍五入到分。
import Decimal from "decimal.js";
// 高精度做中间计算,输出时显式舍入
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export function dec(value: Decimal.Value): Decimal {
return new Decimal(value);
}
/** 舍入到分(2 位),并在边界转回 number */
export function round2(value: Decimal.Value): number {
return new Decimal(value).toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toNumber();
}
ROUND_HALF_UP(四舍五入)而不是 JS toFixed 的银行家舍入,是为了和银行、税务的口径对齐。
二、房贷:等额本息的公式,和一个藏起来的舍入坑
等额本息每月还款额固定,公式是年金现值的逆运算:
M = P × r × (1+r)^n / ((1+r)^n − 1)
其中 P 是本金,r 是月利率(年利率 / 12),n 是期数。代码:
function equalInstallment(input: MortgageInput): MortgageResult {
const { principal, annualRatePercent, termMonths: n } = input;
const r = dec(annualRatePercent).div(100).div(12); // 月利率
let monthly: Decimal;
if (r.isZero()) {
monthly = dec(principal).div(n); // 零利率兜底,别让分母变成 0
} else {
const factor = r.plus(1).pow(n); // (1+r)^n
monthly = dec(principal).mul(r).mul(factor).div(factor.minus(1));
}
const monthlyRounded = round2(monthly);
// ...逐期生成还款计划
}
坑在逐期还款计划这里。月供 monthlyRounded 是四舍五入到分的,每期利息 剩余本金 × 月利率 也要舍入到分,本金部分 = 月供 − 利息。几百期累加下来,舍入误差会让最后剩余本金不是正好 0,可能差几分钱。
处理方式是最后一期不按公式走,直接把剩余本金一次结清,让它吸收掉前面所有的舍入漂移:
let remaining = dec(principal);
for (let period = 1; period <= n; period++) {
const interest = dec(round2(remaining.mul(r)));
let principalPart: Decimal;
let payment: Decimal;
if (period === n) {
// 末期:直接还清剩余本金,吸收逐期舍入误差
principalPart = remaining;
payment = principalPart.plus(interest);
} else {
principalPart = dec(monthlyRounded).minus(interest);
payment = dec(monthlyRounded);
}
remaining = remaining.minus(principalPart);
schedule.push({ period, payment: round2(payment), /* ... */ });
}
少了这一步,还款计划跑到最后会留个「剩余本金 0.03 元」的尾巴,用户一眼就看出 bug。这种细节,是「能用」和「敢上线」的区别。
等额本金(每月本金固定、利息递减)同理,总利息还能用一个简化式做校验:总利息 ≈ P × r × (n+1) / 2,单测里拿它对拍很方便。
在线版可以直接试两种还款方式的对比:https://jsq3000.com/finance/mortgage
三、个税:年终奖的「临界点盲区」,一个反直觉的现象
个税里最有意思的不是累计预扣法,而是年终奖单独计税的临界点——一个像 bug、其实是规则本身造成的陷阱。
单独计税的规则:用 奖金 ÷ 12 的商去查「按月换算税率表」定税率档,但税是按整笔奖金收的:
export function calcBonusSingleTax(bonus: number): BonusSingleTaxResult {
// 用 奖金/12 定档,但对整笔奖金计税
const monthlyAvg = dec(bonus).div(12);
const bracket = findBracket(monthlyAvg, MONTHLY_BRACKETS);
const tax = round2(
dec(bonus).mul(bracket.ratePercent).div(100).minus(bracket.quickDeduction),
);
// ...
}
问题就出在「÷12 定档、整笔计税」这个错配上。看 36000 这个临界点:
| 年终奖 | ÷12 | 适用税率 | 个税 | 到手 |
|---|---|---|---|---|
| 36000 | 3000 | 3% | 1080 | 34920 |
| 36001 | 3000.08 | 10% | 3390.1 | 32610.9 |
多发 1 元,整笔奖金税率从 3% 跳到 10%,个税多了 2310 元,到手反而少了 2300 多。 36000 往上的一小段是「发越多、到手越少」的无效区间,144000、300000 等档位同理。
所以工具里不只是算个税,还得主动探测用户的奖金是否落在临界盲区,提示「再多发一点反而更划算的下一个安全点」。这种「比公式多想一步」的逻辑,才是计算器有没有用的分水岭。
年终奖单独 / 合并计税对比、临界点提示,在线版:https://jsq3000.com/finance/income-tax
四、一点工程化:让「算得对」可维护
两个原则贯穿所有计算器:
1)逻辑只在 core,参数单独抽出来。 税率表、LPR、社保基数这些会随政策变的东西,全部放在 data/ 里,和算法分离:
// data/iit.ts —— 政策一变只改这里,算法零改动
export const COMPREHENSIVE_ANNUAL_BRACKETS: TaxBracket[] = [
{ upTo: 36000, ratePercent: 3, quickDeduction: 0 },
{ upTo: 144000, ratePercent: 10, quickDeduction: 2520 },
{ upTo: 300000, ratePercent: 20, quickDeduction: 16920 },
// ...
];
查表用「速算扣除数」形式:税 = 应纳税所得额 × 税率 − 速算扣除数,避免分段累加,代码更短也更快。
2)边界必测。 个税档位临界、年终奖临界点、提前还款的末期、零利率——这些边界都是单测里的固定用例。算钱的库,单测不绿不敢发版。
整个核心是个纯函数库(packages/core,零平台依赖),Web 端用 Next.js 直接消费源码做 SSG,小程序和 App 也能 import 同一份逻辑,三端口径天然一致,不会出现「网页算出来和小程序不一样」的尴尬。
写在最后
「算钱」的计算器,难点从来不在公式,而在精度、舍入、临界点和政策维护这些细节上。这两个计算器都已经做成了在线版,欢迎拿真实数字去拍:
- 房贷计算器(等额本息/本金 + 提前还款对比):https://jsq3000.com/finance/mortgage
- 个税计算器(累计预扣 + 年终奖临界点提示):https://jsq3000.com/finance/income-tax
如果你也在做涉及金额的业务,decimal.js + 边界舍入 + 末期吸收 + 临界点探测 这套思路应该能帮你少踩几个坑 。
浙公网安备 33010602011771号