症状:合計や消費税がJSの計算で1円ズレる
見積書や請求書の画面をJavaScriptで作ると、税抜合計や消費税、税込合計が「手計算や経理システムと1円だけ合わない」ことがあります。金額そのものは大きく間違っていないのに、末尾が1円ずれる。この手のズレは、原因を「JavaScriptの小数計算(浮動小数点)が悪い」の一言で片づけてしまうと、直したつもりでまた別のところでズレます。
実際には原因はいくつかに分かれていて、しかも一番多いのは浮動小数点そのものではありません。この記事では原因を切り分けたうえで、円の整数で計算する対策と、消費税の端数処理を請求書のどの単位で行うか、というルールまでを動くデモ付きで整理します。コピペで動く合計計算フォームの完成形は合計自動計算フォームの事例ページに置いてあります。
1円ズレの原因の切り分け
請求金額が1円ズレるとき、原因はほぼ次のどれかです。上から順に自分のコードを確認してください。
原因1:小数を含む金額をfloatのまま計算している
従量課金の単価、外貨の換算後金額、割引率の乗算など、円未満の小数が混じる金額を + や * でそのまま計算している。0.1 + 0.2 が 0.30000000000000004 になる浮動小数点の誤差が乗ってきます。
原因2:端数処理を行ごとに何度もやっている
消費税を「明細の行ごとに計算して足し合わせる」実装。行ごとに切り捨てた端数が積み重なって、総額に対して一度だけ計算した金額と1円以上ずれます。業務アプリで最も多く、しかもインボイス制度の要件にも関わります。
原因3:toFixed や Math.round の丸めを誤解している
toFixed() は四捨五入に見えて、実際は2進数で保持した値を丸めるので直感とずれます。Math.round() は負数のときの丸め方向が非対称です。表示のためだけに丸めた値で再計算しているケースもあります。
覚えておきたいのは、円の整数だけを足し算しているうちは、原因1の浮動小数点でズレることはまずないということです。105 + 296 + 460 のような整数どうしの加算に誤差は出ません。1円ズレの多くは、小数を持ち込んだ乗除算(原因1)か、端数処理の回数(原因2)で起きています。順番に見ていきます。
原因1:小数を含む金額のfloat計算
JavaScriptの数値はすべて64ビットの浮動小数点で、0.1 や 19.9 のような10進の小数を正確には表せません。そのため小数を含む金額を計算すると、末尾に微小な誤差が残ります。
0.1 + 0.2; // → 0.30000000000000004 19.9 * 3; // → 59.699999999999996(期待は 59.7)
この 59.699999999999996 を Math.floor() で円未満切り捨てすると 59 になり、意図した 59 と一致することもあれば、誤差の向きしだいで境界をまたいで1円ずれることもあります。厄介なのは、値によって出たり出なかったりするので再現しにくい点です。
対策はシンプルで、金額を円(円未満を扱うなら銭)の整数にしてから計算することです。小数のまま持ち回らず、入力の時点で整数に丸め、加算は整数どうしで行います。
// 円の整数で持てば誤差は出ない const yen = [1980, 2750, 330]; yen.reduce((a, b) => a + b, 0); // → 5060 // 円未満(銭)を扱うなら 100 倍して整数化してから計算し、最後に円へ戻す const sen = [198050, 275025, 33030]; // 1980.50円 などを銭で保持 Math.floor(sen.reduce((a, b) => a + b, 0) / 100); // → 5061
単価×数量→小計→税抜合計を整数のまま積み上げる実装は、合計自動計算フォームの事例ページでそのまま動かせます。ここから先の「端数処理をどこで何回やるか」が、実務での1円ズレの本丸です。
原因2:行ごとの端数処理(失敗と対策のデモ)
消費税は割り算(正確には ×0.1 の乗算)を含むので、必ず端数が出ます。このとき行ごとに消費税を計算して足し合わせるのか、税抜合計に対して一度だけ計算するのかで、税込合計が変わります。下のデモは同じ明細を両方の方法で計算しています。単価や数量を変えて、右下の差を見てください。
消費税10%・端数は切り捨て。単価・数量を変えると、明細の内容によって税込合計が1円ずれます。
| 品名 | 単価(円) | 数量 | 小計(円) | 行の消費税 |
|---|
税抜合計:0 円
行単位課税(行ごとに切り捨てて合計)
総額単位課税(合計に1回だけ切り捨て)
行ごとに切り捨てると、切り捨てた端数が行の数だけ積み重なります。総額に対して一度だけ切り捨てた場合よりも消費税が小さくなりやすく、その差がそのまま税込合計の1円ズレになります。コードで並べると違いは1行だけです。
// 行単位課税:行ごとに端数処理して足す(ズレる) const taxByLine = rows.reduce( (sum, r) => sum + Math.floor(r.price * r.qty * 0.1), 0); // 総額単位課税:税抜合計に対して1回だけ端数処理する const subtotal = rows.reduce((sum, r) => sum + r.price * r.qty, 0); const taxByTotal = Math.floor(subtotal * 0.1);
どちらが正しいのかは「請求書として何が求められるか」で決まります。次に、消費税の端数処理のルールを確認します。
インボイス制度の端数処理ルール
2023年10月に始まったインボイス制度(適格請求書等保存方式)では、消費税の端数処理について明確なルールがあります。1つの適格請求書につき、税率ごとに1回だけ端数処理を行うというものです。つまり、明細の行ごとに消費税を計算して端数処理し、それを積み上げる方法は認められていません。
税率の異なるごとに区分した合計額に対して、1回の端数処理を行う。行(商品)単位で端数処理して合計する方法は不可。
先ほどのデモでいえば、「総額単位課税」が制度に沿った計算です。10%と8%(軽減税率)が混在する請求書なら、10%対象の合計・8%対象の合計をそれぞれ出し、税率ごとに1回ずつ端数処理します。端数を切り捨てるか切り上げるか四捨五入するかは事業者が選べますが、処理の回数(税率ごとに1回)は選べません。実装が原因2のように行ごとに丸めていると、金額がズレるだけでなく要件からも外れます。正確な条文は国税庁の 適格請求書等保存方式(インボイス制度)に関する資料 で確認してください。
切り捨て・切り上げ・四捨五入の選び方
端数処理の方法そのものは、法律で一つに決められているわけではありません。取引先との取り決めや自社の会計ルールが最優先です。すでに使っている経理システムや、継続取引の請求書の慣習に合わせるのが基本で、実装の都合で勝手に決める場所ではありません。
そのうえで、社内でとくに決まりがなく新規に決める場合の既定としては、切り捨てを選んでおくのが無難です。請求先に対して過大請求になりにくく、昔からの商習慣としても切り捨てが多いためです。この記事のデモや合計自動計算フォームの事例も切り捨てで統一しています。切り上げや四捨五入が必要なのは、取引先や自社ルールで明示的に指定されているときだと考えてください。JavaScriptでの書き方は次のとおりです。
const tax = subtotal * 0.1; Math.floor(tax); // 切り捨て(既定として推奨) Math.ceil(tax); // 切り上げ Math.round(tax); // 四捨五入(負数の扱いに注意。後述)
フロントの計算とサーバーの確定額
ここまでの計算はすべてブラウザのJavaScriptで動きますが、実務ではフロントの計算はあくまで入力中のUXフィードバック用と割り切るのが安全です。ユーザーが単価や数量を打つそばから合計が更新されるのは体験として重要ですが、その値をそのまま請求金額として確定してはいけません。
請求を確定するタイミングでは、サーバー側でもう一度同じ計算を行い、サーバーが出した金額を正とします。理由は主に2つあります。1つは、フロントのコードは利用者側で書き換えられるため、送られてきた金額をそのまま信用できないこと。もう1つは、端数処理のルールや税率が変わったときに、フロントとサーバーで計算ロジックを二重に保守していると、片方だけ直し忘れて金額がズレる事故が起きることです。
金額の計算ロジックはサーバー側を正本にして一箇所にまとめ、フロントは表示のための概算として同じルールをなぞる。この役割分担をあらかじめ決めておくと、後から「画面の金額と確定請求額が違う」というトラブルを避けられます。
エッジケース一覧
1円ズレに関わる細かい挙動を表にまとめます。どれもブラウザのコンソールで実際に確認した結果です。
| ケース | 何が起きるか | 対策 |
|---|---|---|
| 行単位課税と総額単位課税 | 同じ明細でも税込合計が1円以上変わる。行ごとに切り捨てると消費税が小さくなりやすい | 税率ごとに合計してから1回だけ端数処理する(インボイス要件) |
toFixed() の丸め |
(1.005).toFixed(2) は "1.01" ではなく "1.00"。(8.575).toFixed(2) は "8.57"。2進数で保持した実際の値を丸めるため直感とずれる |
金額の確定に toFixed を使わず、整数で計算してから表示だけ整形する |
Math.round() の負数 |
半分の値は正の無限大方向へ丸める。Math.round(-0.5) は -0(-1 ではない)、Math.round(-1.5) は -1。値引き・返金など負の金額で非対称になる |
負数を扱うなら符号を分けて絶対値で丸めるか、切り捨て・切り上げの向きを明示する |
| 内税から税抜への割り戻し | 税込1080円の税抜は 1080 / 1.1 = 981.818...。割り戻しは必ず端数が出るので、丸め方しだいで消費税額が1円変わる |
割り戻しの端数処理ルールも取り決めておく。Math.floor(1080 / 1.1) なら税抜981円 |
| 表示だけ丸めて内部値がズレる | toLocaleString() やCSSで見た目だけ丸め、内部では小数のままの値で再計算すると、画面の各行を足しても合計に一致しない |
丸めた「確定値」を変数として保持し、以降の計算も表示もその整数を使う |
| 軽減税率(8%と10%)の混在 | 税率ごとに端数処理が必要。全明細をまとめて1回で計算すると税率区分が崩れ、税額がずれる | 税率で明細をグループ分けし、グループごとに合計・端数処理してから足し合わせる |
まとめ
請求金額の1円ズレは、浮動小数点だけの問題ではありません。原因を切り分けると、対策は次の3点に集約されます。
金額は円(必要なら銭)の整数で計算する → 消費税の端数処理は税率ごとに1請求書で1回だけ行う → 確定額はサーバー側の計算を正とする。
とくに原因2の「行ごとに端数処理して積み上げる」は、金額がズレるだけでなくインボイス制度の要件からも外れるので、総額(税率ごと)で1回に統一してください。端数の丸め方は取引先や自社ルールが最優先で、決まりがなければ切り捨てを既定にしておけば無難です。コピペで動く整数計算の完成形は、下の関連事例で確認できます。
関連するUI事例
この記事の対策(整数で計算する・総額単位で端数処理する)を実装した、コピペで動くフォームや出力の事例です。