用 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 同一份逻辑,三端口径天然一致,不会出现「网页算出来和小程序不一样」的尴尬。

写在最后

「算钱」的计算器,难点从来不在公式,而在精度、舍入、临界点和政策维护这些细节上。这两个计算器都已经做成了在线版,欢迎拿真实数字去拍:

如果你也在做涉及金额的业务,decimal.js + 边界舍入 + 末期吸收 + 临界点探测 这套思路应该能帮你少踩几个坑 。

posted @ 2026-06-26 12:22  开发大东  阅读(9)  评论(0)    收藏  举报