はじめに
金額を扱うコードを書くとき、たいてい最初に叩き込まれる定石があります。
金額は float で持つな。整数の「最小単位(minor unit)」で持て。
$12.34 を 1234(セント)として int に押し込む、あれです。丸め誤差を避けるための鉄則で、Stripe をはじめ多くの決済 API がこの形(amount はセント建て)を採っています。筆者もこの定石には全面的に同意します。
ただ、この定石には言われない前提があります。「最小単位」は通貨ごとに違う、という点です。ここを踏むと、名前だけを見て書かれたコードが ×100 バグを生みます。この記事は、筆者のチームが開発する書類保管サービス(nene-vault)で実際に踏んだ ×100 バグと、それを二度と踏まないための「規約を1行に固定してテストで守る」設計を、実コードから掘り下げます。
この記事で分かること:
- 「金額は最小単位で持て」の定石に隠れた前提=最小単位は通貨依存(USD は 2 桁・JPY は 0 桁)
- 名前は
amount_centsでも中身は円——この食い違いが実際に×100を呼んだ経緯と、規約を単一の通り道(フォーマッタの docblock)に固定する対処 -
×100を消すだけでなく「¥33,000〜¥3,300,000 の帯」で一括の再混入を落とす、軽い smoke の回帰テスト(とその限界)
「最小単位」は通貨依存 —— JPY の minor unit は 0 桁
通貨の最小単位の桁数は、ISO 4217 が exponent(指数) として定義しています。
| 通貨 | exponent | 最小単位 |
1 が意味する額 |
|---|---|---|---|
| USD | 2 | セント | $0.01 |
| EUR | 2 | セント | €0.01 |
| JPY | 0 | (なし) | ¥1 |
USD は 1 ドル=100 セントなので exponent は 2。だから「セントで持つ」=「額 × 100 を整数で持つ」で正しい。
一方 JPY の exponent は 0 です。円より下の単位(銭・厘)は 1953 年の小額通貨整理法で通貨として廃止され、いまの日本円に流通する補助単位は存在しません。つまり JPY の最小単位はすでに「円」そのもの。1 は ¥1 を意味し、×100 する余地はどこにもない。
ところが、このコードは実際に書かれました。しかも「円に銭はない」を体で知っている日本人の手からではありません。
amountCents: $total * 100,
入ったのは AI と協働したコミットです(Co-Authored-By: Claude・2026-07-10・デモ基盤の整備)。書き手が見ていたのは通貨ではなく識別子の名前でした。amountCents と書いてあれば、* 100 は英語圏のコード——amount がセント建てである Stripe 的な世界——ではいちばん自然な続きです。「このプロダクトは JPY 単一通貨である」という文脈は、変数名のどこにも書かれていません。
これが ×100 バグの正体です。通貨の知識が足りなかったのではなく、名前が実態と食い違っているコードベースに、名前しか読まない書き手が入った。人間なら円に銭が無いことを知っているから防げる、とも言えません——次に同じ場所を触るのが誰(あるいは何)かは、選べないからです。
amount_cents=円 という規約 —— 名前は cents、中身は円
nene-vault の金額カラム/フィールドは amount_cents という名前です。プロジェクト全体で snake_case・最小単位・nullable integer という命名規約に揃えてあり、命名規約ドキュメントにこう定義されています。
- Money amounts … nullable integer cents(
amount_cents、額が無ければ null) - Money columns …
*_centsサフィックス、nullable integer
コーディング規約側でも金額の扱いを釘刺ししています。
amount_cents: nullable integer in JPY.nullmeans the document carries no monetary value.- Float and DECIMAL for money are prohibited in DB, API JSON, and tests.
ポイントは、名前は _cents だが中身は円、という点です。JPY 単一通貨のプロダクトなので、_cents はあくまで「最小単位で持つ」という設計方針を示す接尾辞であって、「1/100 円」ではない。ここが暗黙知のままだと、名前に引きずられて誰かが必ず ×100 を書きます。
そこでこの暗黙知を、フォーマット関数の docblock に規約として明文化しました。金額を表示に変換するのはこの1関数だけ、という単一の通り道(frontend/src/shared/lib/format.ts)にコメントを置いています。原文はこうです。
/**
* Format an integer-yen amount as currency for the active locale.
* JPY has no minor unit, so `amount_cents` is whole yen (see naming-conventions).
* Returns an em dash for null (document carries no monetary value).
*/
export function formatJpy(cents: number | null, locale: SupportedLocale): string {
if (cents === null) {
return EM_DASH;
}
return new Intl.NumberFormat(INTL_LOCALE[locale], {
style: 'currency',
currency: 'JPY',
}).format(cents);
}
JPY has no minor unit, so amount_cents is whole yen。この1行が規約の本体です。しかも Intl.NumberFormat に currency: 'JPY' を渡しているので、フォーマッタ自身が ISO 4217 の exponent=0 を知っていて、小数部を付けません。ここも定石で ÷100 してから渡す、みたいな細工は一切要らない。値をそのまま渡すのが正解です。
×100 バグと、それを固定した回帰テスト
踏んだ場所はデモデータ生成でした(src/Demo/DemoDataSeeder.php:122)。書類ごとの合計金額を生成し、amount_cents として保存する箇所です。
$total はすでに円建ての整数です。それを ×100 したせいで、デモ組織の一覧には本来 ¥1,493,000 と出るべき額が ¥149,300,000(約 1.5 億円)と表示されました。桁が2つズレた、いかにも「気づきにくいが見ればおかしい」バグです。
修正(#138、報告は #136)は拍子抜けするほど単純で、×100 を消しただけ。ただしなぜ消したかをコメントに残しています。
// JPY has no minor unit: amount_cents stores whole yen
// (naming-conventions), matching the PDF total. Never x100.
amountCents: $total,
Never x100. —— 次にこのコードを触る人へのメッセージです。
そして重要なのはここから。「消した」だけでは、半年後に別の誰かがまた ×100 を足しかねない。だから再混入したら落ちるテストを足しました(tests/Demo/DemoSeedLifecycleTest.php)。
// Weak oracle (kept, demoted): a plausibility band on the aggregate.
// Amounts are whole yen (JPY has no minor unit — naming-conventions) and
// the line generator yields ¥33,000–¥3,300,000 per document. This is only
// a "does the magnitude look sane" smoke test, NOT the real invariant: a
// ×100 on a small row (e.g. ¥33,000 → ¥3,300,000) lands on the upper bound
// and passes, and because it reads MIN/MAX only, any ×100 that does not
// move the aggregate is invisible. Cheap smoke, not a guarantee.
$amounts = $this->query()->fetchOne(
'SELECT MIN(amount_cents) AS lo, MAX(amount_cents) AS hi FROM vault_documents WHERE organization_id = ?',
[$org->id],
);
self::assertIsArray($amounts);
self::assertGreaterThanOrEqual(33_000, (int) $amounts['lo']);
self::assertLessThanOrEqual(3_300_000, (int) $amounts['hi']);
やっていることは、生成された金額が ¥33,000〜¥3,300,000 の現実的な帯に収まっているかの上下限チェックだけ。今回のような一括の ×100 が再混入すれば MAX が上限を大きく超えるので、テストは赤くなります。ただしコメントにあるとおり、これは桁のオーダーを見る smoke テストであって完全な保証ではありません(最小の ¥33,000 の行だけが ×100 されるとちょうど上限に乗って通ります)。そのため後日、同じテストに「各書類の amount_cents =その書類の PDF に印字された総額」という等値の不変条件を追加し(PR #187)、この帯チェックは smoke として残す形にしています。
表示側にも、最小単位を取り違えていないことを固定する小さなテストを置いています(frontend/src/shared/lib/format.test.ts)。
it('has no fractional digits (JPY has no minor unit)', () => {
expect(formatJpy(1000, 'en')).not.toContain('.00');
});
1000 を渡して .00(小数部)が出ないこと。JPY を誤って exponent=2 の通貨のように扱っていないかの番人です。
学び
この一件から持ち帰れる原則は3つです。
-
「金額は最小単位で持つ」は正しい。ただし最小単位は通貨依存。 USD の exponent は 2、JPY は 0。「cents だから ×100」は USD の話であって、JPY では最小単位=円。ISO 4217 の exponent を通貨ごとに確認する。多通貨なら額と通貨コードを組で持ち、桁数は通貨から引く。
-
名前が実態と食い違うなら、規約を1箇所に明文化する。
amount_centsという名前は「1/100 円」を連想させる罠だった。名前を変えるのが難しいなら、せめて変換の単一の通り道(format.ts)に規約コメントを置き、命名規約ドキュメントから参照させる。暗黙知のままなら、次にそこを触る誰か——人間とはかぎらない——が×100を書く。 -
バグは消すだけでなく、再混入を落とすテストで固定する。 正確な金額でなくてよい。「¥33,000〜¥3,300,000 の帯」というオーダーの上下限だけでも、一括の
×100の再発は捕まる(境界に乗る部分的な再発まで塞ぐなら、PDF 印字額との等値のような強い不変条件が要る)。コメントにNever x100.と一言残すのも安いが効く。
Intl.NumberFormat に currency: 'JPY' を渡せば、フォーマッタは exponent=0 を知っていて正しく振る舞います。困るのは、フレームワークでも、×100 を書いた手でもありません。_cents という名前を用意して置いておいた側のほうです。名前は、それを読む人(と AI)への仕様書として機能してしまう。通貨の最小単位は通貨が決める。名前が決めるものではありません。
一次資料
- 規約コメント:
nene-vaultfrontend/src/shared/lib/format.ts(formatJpy) - 命名/金額規約:
docs/development/naming-conventions.md§3・docs/development/coding-standards.md「Money」 - ×100 バグ修正:
src/Demo/DemoDataSeeder.php(PR #138、報告 #136) - 回帰テスト:
tests/Demo/DemoSeedLifecycleTest.php/frontend/src/shared/lib/format.test.ts(帯チェック→PDF 印字額との等値オラクルへの昇格は PR #187)
関連記事
- 同じ #136/#138 を題材にした姉妹記事あり(そちらは QA プロセス論=「営業スクショ撮影が最強の QA だった話」/本記事は金額規約の設計論)。※姉妹記事は現時点で未公開のため、公開後に相互リンクを追記する。
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp