ジャーナル
エンジニアリング1分で読めます

金額計算は整数のセントで、そして消えてしまった割引

50セントの割引をしたのに、台帳にはその形跡がまったく残っていませんでした。しかも犯人は浮動小数点ですらありませんでした。整数で金額計算をするための実践ガイドです。上限と下限の押さえ方、割引の按分、そして単価の台帳の合計をぴったり正しい金額に一致させる方法をまとめます。

BエBookatu エンジニアリングチーム

0.1 足す 0.2 は 0.3 にならない、というのはエンジニアなら誰でも早い段階で学びます。だから私たちは金額を整数のセントで保存し、それで安心してしまいます。ところがカート全体に対するパーセント割引のような実際の機能を作ってみると、整数が解決してくれるのは最初の問題だけだと分かります。二つ目の問題は、割り算があなたの台帳の都合など気にしてくれないことです。

消えた50セント

私たちが実際にはまった状況を、骨だけにして書きます。カートには1ドルの商品が100個入っています。お客様は注文から50セントの割引を受けます。小計は10,000セント、割引は50セント、割引後の合計は9,950セントです。ここまではすべて整数の話です。

この売上を、会計システムが好むように明細ごとの単価を保存する台帳に記録してみます。割引後の単価は 9,950 割る 100 で、99.5 セントです。整数ではないので 100 に丸められます。台帳には100個を単価100セントで、と記録され、定価販売になってしまいました。割引は小さくなったのではありません。消えてしまったのです。画面はお客様に 9,950 と約束したのに、帳簿は 10,000 と言っている状態です。

js
// The trap: one rounded unit price cannot represent every total.const subtotal = 100 * 100;              // 10000cconst discounted = subtotal - 50;        // 9950cconst unit = Math.round(discounted / 100); // 100c ... the discount is goneconsole.log(unit * 100);                 // 10000c, not 9950c // The fix: split the line into two prices one cent apart.const qty = 100;const base = Math.floor(discounted / qty);   // 99cconst rem = discounted - base * qty;         // 50 units get one extra cent// rem units at (base + 1), the rest at base:// 50 * 100 + 50 * 99 === 9950  ✓ exact, and every price is an integer

明細を分けるやり方は、それで何が手に入るかを見るまでは面倒に思えます。100セントが50個、99セントが50個で、合計はちょうど 9,950 です。どの行も正直な整数の単価を保存したままで、数量も合い、台帳の合計はレシートの金額と1セントまで一致します。丸めた単価を1つだけ持つやり方では、そのどれも実現できません。

按分には帳尻合わせの一手間が要る

同じ病気は、注文全体の割引を複数の明細に配分するときに、一段上のレベルで現れます。各明細に比率どおりの取り分を与えて丸めると、その合計は割引額にほぼ一致します。1セント多いこともあれば、1セント少ないこともあり、たまにぴったり合うこともあります。実はこのぴったり合う場合が一番やっかいで、軽くテストしただけではバグが隠れてしまいます。

js
// Prorate by share, then push the rounding leftover onto lines with room.let allocated = 0;for (const line of lines) {  line.discount = Math.min(    line.subtotal,    Math.round((discount * line.subtotal) / orderSubtotal),  );  allocated += line.discount;}let remainder = discount - allocated; // may be negativefor (const line of lines) {  if (remainder === 0) break;  const room = remainder > 0 ? line.subtotal - line.discount : line.discount;  const move = Math.min(Math.abs(remainder), room) * Math.sign(remainder);  line.discount += move;  remainder -= move;}// invariant: sum(line.discount) === discount, and no line goes negative

このループには、痛い目を見て身についた工夫が二つあります。一つは、余りを最後の明細だけでなく、余裕のあるすべての明細に配ることです。最後の明細が6セントの商品で、余った3セントを吸収できないこともあるからです。もう一つは、各明細の取り分をその明細の小計までに抑えることです。明細より大きい割引は単価がマイナスになることを意味し、マイナスの単価は台帳が逆方向に嘘をつき始める入り口だからです。

小計、10パーセントの割引、そして正確な割引後の合計が表示されたPOS画面
この地味なレシートこそが狙いです。小計、割引、そして台帳が1セントまで再現できる合計。

境界はすべて押さえる

  • 割引はゼロから小計までの範囲に収めます。110パーセント引きは入力ミスであって、返金ではありません。
  • 手入力された金額は慎重に読み取ります。空欄や数字でない入力はゼロとして扱い、計算の途中で NaN が出ないようにします。
  • 数量は実在する在庫までに抑えます。金額のコードは、その手前にある在庫のバグをすべて引き継いでしまいます。
  • 割り切れない合計こそテストします。10ドルの商品3点から1ドル引き、といったケースのほうが、きりのよい数字よりずっと良いテストになります。

どれも華やかな話ではなく、だからこそレビューをすり抜けます。計算式は正しく見えますし、実際に正しいのです。整数の割り算が、誰かが受け取るはずだった余りを黙って捨てるまでは。部分の合計は全体に等しい、という不変条件を書き出し、それをテストに見張らせてください。

整数は金額を正直にします。帳尻合わせは整数を正直にします。

セント単位の金額浮動小数点 通貨割引の按分丸め誤差決済 エンジニアリング
さっそく試してみませんか。

Bookatuなら、あなたのブランドの予約ページ、デポジット、会員制度、ギフトカード、リマインダーが揃って、予約手数料は0%です。

無料で始める