東京の顧客が 19.99 ドルのサブスクリプションを購入します。コードは USD/JPY レートを掛けて 2942.82785 を得て、データベースに書き込み、決済プロバイダーへ送信します。プロバイダーはそれを拒否する——あるいはもっと悪いことに、受理して 100 倍の金額を請求します。円に小数位はありません。そしてコードはそれを一度も確認しませんでした。
通貨の丸めは、本番環境に届くまでは些細に見える類いの問題です。これは書式の問題ではなく、正しさの問題です。通貨ごとに小数位数は異なり、浮動小数点演算は金額を静かに壊し、通貨を換算した瞬間に、意図的に下さなければならない丸めの判断が生じます。本ガイドでは実務で本当に効くルールを扱います。各通貨の小数位数、金額を整数で保持すべき理由、どの丸めモードを選ぶか、そして月末に元帳が合うように為替換算をどう丸めるか、です。
補助単位:通貨ごとの小数位数は?
通貨の補助単位とは、取引可能な最小の細分単位です。米ドルではセントであり、USD は小数 2 桁、19.99 ドルは 1999 セントになります。これが決済プロバイダーの期待する表現であり、データベースが使うべき表現です。
ISO 4217——USD や JPY といった 3 文字コードを与えるのと同じ規格——は、各通貨に補助単位の指数も割り当てています。多くの開発者はこの指数が常に 2 だと思い込みます。そうではありませんし、この思い込みこそがこの領域で最も高くつくバグです。
小数位のない通貨
これらの通貨には流通する補助単位がないため、送信する金額がそのまま単位の整数値になります。
- JPY — 日本円
- KRW — 韓国ウォン
- VND — ベトナム・ドン
- CLP — チリ・ペソ
- ISK — アイスランド・クローナ
- XAF / XOF / XPF — CFA フラン・CFP フラン
- UGX — ウガンダ・シリング
- PYG — パラグアイ・グアラニー
- RWF、GNF、KMF、DJF、VUV — ほか、小額面の通貨がいくつか
JPY を小数 2 桁の通貨として扱い、プロバイダーへ送る前に 100 を掛けたなら、顧客に意図した金額の 100 倍を請求したことになります。
小数 3 桁の通貨
7 つの通貨は 100 分の 1 ではなく 1000 分の 1 に細分されます。
- KWD — クウェート・ディナール(1000 フィルス)
- BHD — バーレーン・ディナール(1000 フィルス)
- OMR — オマーン・リアル(1000 バイザ)
- JOD — ヨルダン・ディナール(1000 フィルス)
- TND — チュニジア・ディナール(1000 ミリム)
- IQD — イラク・ディナール(1000 フィルス)
- LYD — リビア・ディナール(1000 ディルハム)
ここでは失敗の向きが逆になります。KWD を小数 2 桁として扱えば、意図した額の10 分の 1しか請求できません。KWD 12.500 の請求書が KWD 1.250 になってしまいます。
ISO 4217 には小数 4 桁の項目さえあります——チリのウニダ・デ・フォメント(CLF)とウルグアイのウニダ・プレビシオナル(UYW)です。これらは現金ではなく指数連動の会計単位ですが、システムが任意の ISO コードを受け付けるなら、これらにも耐える必要があります。
規格とプロバイダーが食い違うとき
これは、ほかを全部正しくやったチームが引っかかる罠です。決済プロバイダーは運用上の理由で ISO 4217 から逸脱することがあります。たとえば Adyen は、CLP、CVE、IDR、ISK が API 上では規格と異なる小数位数を取ると明記しています——ISK は ISO 4217 では小数 0 桁ですが、Adyen には小数 2 桁で送る必要があります。
原則はこうです。丸めテーブルは、あなたが会話している相手システムの属性であって、普遍の定数ではありません。 連携ごとに 1 つのテーブルを持ち、ISO 4217 を初期値とし、相手のドキュメントが指示する箇所でプロバイダー別に上書きしてください。100 をハードコードしないこと。
金額を float で保存してはいけない
丸めの議論の前に、土台の話です。2 進浮動小数点はほとんどの 10 進小数を正確に表現できません。
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
19.99 * 147.2150 // 2942.8278499999997これらの末尾の桁は見た目の問題ではありません。誤ったタイミングで Math.round() に通せば、補助単位 1 つ分ずれた金額になり、それだけで照合が落ちます。
ほぼすべてのケースは 2 つのルールで足ります。
- 金額は補助単位の整数で保存する。
amount_minor BIGINT列とcurrency CHAR(3)列。{ amount_minor: 1999, currency: "USD" }は曖昧さがなく、Stripe や Adyen をはじめ多くのプロバイダーが既に期待している形です。 - 演算は整数か 10 進型で行う。 Python の
decimal.Decimal、Java のBigDecimal、PostgreSQL のNUMERIC、あるいは整数演算をラップした JavaScript の金額ライブラリ。float は為替レートそのものに限定し、それも掛け算の時点までにとどめます。
この層をゼロから設計するなら、マルチ通貨の元帳設計のガイドがスキーマ判断をより深く扱っています。
換算パイプライン:補助単位で入り、補助単位で出る
通貨換算はちょうど 4 ステップで、丸めはステップ 3 に属します——最後に、1 回だけ。
- 元の金額を補助単位から 10 進値に変換する。
- フル精度の為替レートを掛ける。
- 対象通貨の補助単位指数に丸める。
- 整数の補助単位へ戻す。
JavaScript で書くとこうなります。通貨別テーブルが仕事をします。
// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};
function exponentFor(currency) {
const e = MINOR_UNITS[currency];
if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
return e;
}
/**
* Convert an integer minor-unit amount from one currency to another.
* Returns an integer in the target currency's minor units.
*/
function convertMinor(amountMinor, from, to, rate) {
const fromExp = exponentFor(from);
const toExp = exponentFor(to);
const decimalAmount = amountMinor / 10 ** fromExp; // 1999 -> 19.99
const converted = decimalAmount * rate; // full precision, no rounding yet
return Math.round(converted * 10 ** toExp); // single rounding step
}
convertMinor(1999, "USD", "JPY", 147.2150); // 2943 (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590); // 6115 (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241); // 1847 (€18.47)この関数がやっていないことに注目してください。レートを丸めない、中間値を丸めない、小数 2 桁を前提にしない。ここでの Math.round は正の数に対する四捨五入です——チェックアウトなら問題ありませんが、規制対象の用途に使う前に次節を読んでください。
丸めモードを選ぶ
「小数 2 桁に丸める」は仕様ではありません。同点を割る方法は少なくとも 5 通りあり、金融システムはどれを選ぶかを気にします。
| モード | 2.5 → | 3.5 → | −2.5 → | 典型的な用途 |
|---|---|---|---|---|
| 四捨五入(half up) | 3 | 4 | −3 | 消費者向け価格、チェックアウト合計 |
| 銀行家丸め(half even) | 2 | 4 | −2 | 会計、利息、税務、レポーティング |
| half down | 2 | 3 | −2 | まれ。レガシーな金融コードで見かける程度 |
| 切り上げ(ceiling) | 3 | 4 | −2 | 過少徴収が許されない手数料 |
| 切り捨て(floor) | 2 | 3 | −3 | 過大支払いが許されない送金 |
Math.round() の挙動です。直感的で、顧客がこれから目にする価格に適しています。half even(銀行家丸め)は、ちょうど半分の値を最も近い偶数桁へ送ります。多数の取引を通じて half up が生む系統的な上方バイアスを打ち消すため、会計システム、Python の decimal モジュール、そして IEEE 754 自体の既定になっています。何千件もの換算額を売上レポートに集計するなら、half up は合計を静かに膨らませます。half even はそうしません。
切り上げと切り捨ては非対称なリスクのために存在します。売り手へ送金するマーケットプレイスは、保有額を超えて分配しないよう各送金を切り捨てることがあります。差額は丸め勘定に入ります。
Python はこの選択を明示させます。これが正しい設計です。
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}
def convert_minor(amount_minor: int, src: str, dst: str,
rate: str, mode=ROUND_HALF_EVEN) -> int:
"""Convert integer minor units to integer minor units, exactly once."""
src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]
amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
converted = amount * Decimal(rate) # rate passed as a string, not a float
quantum = Decimal(1).scaleb(-dst_exp) # 0.01, 1, or 0.001
rounded = converted.quantize(quantum, rounding=mode)
return int(rounded.scaleb(dst_exp))
convert_minor(1999, "USD", "JPY", "147.2150") # 2943
convert_minor(1999, "USD", "KWD", "0.30590") # 6115レートを文字列として Decimal に渡すことが重要です。Decimal(0.9241) は float の誤差を引き継ぎますが、Decimal("0.9241") は引き継ぎません。
実際にお金を失う 3 つの丸めバグ
1. 掛ける前にレートを丸める
為替レートは通常 4〜6 桁の有効小数を持ち、それを切り捨てるのは無害ではありません。USD/JPY 147.2150 で 10,000 ドルを送金する場合:
- 完全なレート:
10000 × 147.2150 = ¥1,472,150 - 小数 2 桁に丸めたレート(147.21):
10000 × 147.21 = ¥1,472,100
たった 1 件の取引で ¥50 の差。レートを使う前に整形しただけでこうなります。プロバイダーが返す精度でレートを保存し、丸めるのは結果金額だけにし、監査のために実際に使ったレートを取引と一緒に永続化してください。為替レート API はどこからデータを取得しているのかのガイドが、そもそもなぜその精度に意味があるのかを説明しています。
2. 多段換算で 2 回丸める
USD → EUR → JPY と経由し EUR の段階で丸めると、2 回目の掛け算が増幅する精度を捨てたことになります。USD/EUR 0.9241、EUR/JPY 159.3063 で 12.34 ドルを換算:
- 直接:
12.34 × 147.2150 = 1816.63→ ¥1,817 - 丸めた EUR を経由:
12.34 × 0.9241 = 11.4034→ €11.40 に丸め →11.40 × 159.3063 = 1816.09→ ¥1,816
不要な丸め 1 回で 1 円。5 万件の送金バッチなら、これは照合チケットになります。直接ペアが使えるなら常にそれを使い、三角換算が必要なときは中間値をフル精度のまま保ってください。仕組みはクロスレート解説を参照。
3. 明細が合計に合わない
請求書の各行を独立に丸めると、各部の和が丸めた合計に一致するとは限りません。典型例は分割です。
$10.00 split three ways
10.00 / 3 = 3.3333...
→ 3.33 + 3.33 + 3.33 = 9.99 ✗ one cent missing正解は丸めではなく按分です。合計を 1 回丸めてから各部に配分し、余りを補助単位 1 つずつ配っていきます。
/**
* Split an integer minor-unit total into `n` parts whose sum is exactly the total.
* Remainder units are distributed to the earliest parts (largest-remainder method).
*/
function allocate(totalMinor, ratios) {
const sum = ratios.reduce((a, b) => a + b, 0);
const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);
for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
shares[i] += 1;
}
return shares;
}
allocate(1000, [1, 1, 1]); // [334, 333, 333] → sums to exactly 1000
allocate(9247, [3, 2, 1]); // [4624, 3082, 1541] → sums to exactly 9247為替換算後も同じパターンを使います。請求書合計を換算して丸め、その合計を各行に按分するのです。各行は合計から導かれているので必ず一致します。これはマルチ通貨請求や日割り計算のある SaaS 課金でとりわけ重要で、1 セントのズレが顧客の見る PDF に現れます。
現金の丸めは別のルール
通貨の補助単位は記帳できる最小額を示します。現金で支払える最小額を常に示すわけではありません。いくつかの国は最小硬貨を廃止し、レジで現金支払いを丸めています。
- スイス — 現金は 0.05 CHF 単位に丸め
- カナダ — 1 セント硬貨は 2013 年に廃止。現金は 5 セント単位に丸め
- スウェーデン — 現金は 1 クローナ単位に丸め
- オランダ — 現金は 5 セント単位に丸め
重要なのは、これが現金の受け渡しに適用されるのであって請求書には適用されないことです。CHF 12.32 のスイスの請求書は 12.32 のまま記帳され、現金決済のみ 12.30 に丸められ、0.02 の差は丸め調整として計上されます。POS ソフトを作るなら、現金の丸めは支払いに適用される独立した後段のステップとしてモデル化してください——保存金額に混ぜ込んでしまうと、電子取引と現金取引が食い違います。
書式化は最後の工程であって計算ではない
演算が終わったら、表示はロケール対応のフォーマッターに任せます。Intl.NumberFormat は各通貨の小数桁数、記号位置、区切り文字をすでに知っています。
function formatMinor(amountMinor, currency, locale = "en-US") {
const exp = exponentFor(currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 10 ** exp);
}
formatMinor(1999, "USD"); // "$19.99"
formatMinor(2943, "JPY", "ja-JP"); // "¥2,943"
formatMinor(6115, "KWD"); // "KWD 6.115"
formatMinor(1847, "EUR", "de-DE"); // "18,47 €"実務上の注意が 2 点。まず Intl.NumberFormat のインスタンス生成は高コストなので、行ごとに作るのではなくロケールと通貨の組ごとにキャッシュしてください。次に、最終行の 10 ** exp による除算は、float が金額に触れてよい唯一の場所です。しかもそれは、結果が直ちに文字列になるからにすぎません。
Finexly API で組み合わせる
レートをフル精度で取得し、1 回換算し、1 回丸め、使ったレートを保存します。
curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
-H "Authorization: Bearer YOUR_API_KEY"{
"success": true,
"base": "USD",
"timestamp": 1755244800,
"rates": {
"JPY": 147.2150,
"KWD": 0.30590,
"EUR": 0.9241
}
}async function quote(amountMinor, from, to) {
const res = await fetch(
`https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
{ headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
);
const data = await res.json();
const rate = data.rates[to];
return {
amount_minor: convertMinor(amountMinor, from, to, rate),
currency: to,
rate, // persist the exact rate used
rate_timestamp: data.timestamp, // and when it was captured
};
}
await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }取引行に rate と rate_timestamp を保存しておくことが、半年後の紛争に答えられるようにする鍵です。エンドポイントとパラメーターの詳細は Finexly API ドキュメントにあります。リクエスト間でレートをキャッシュするなら、キャッシュとエラー処理のノートが鮮度のトレードオフを扱っています。
テストのチェックリスト
金額のバグは、誰もテストを書かないケースに潜みます。最低限これらを網羅してください。
- 小数 0 桁の通貨への換算 — JPY や KRW へ換算し、結果に小数部がないことを検証する。
- 小数 3 桁の通貨への換算 — KWD や BHD へ換算し、3 桁が保たれることを検証する。
- ちょうど半分の値 — 選んだ丸めモードを、負値も含め双方向で検証する。
- 往復のドリフト — USD → EUR → USD と換算し、結果が補助単位 1 つ以内であること(等しいことではない)を検証する。
- 按分の不変条件 — 分割した各部の和が常にちょうど合計になることを、1〜100 分割で検証する。
- 未知の通貨コード — 黙って小数 2 桁にフォールバックせず、例外を投げることを検証する。
- 非常に大きな金額 — JavaScript で
Number.MAX_SAFE_INTEGERを超えて精度が落ちないことを検証する。IDR や VND を大規模に扱うならBigIntを使う。
よくある質問
各通貨の小数位数は何桁ですか?
大半は 2 桁です。JPY、KRW、VND、CLP、ISK を含む 20 数種はゼロ桁で、7 種は 3 桁です(KWD、BHD、OMR、JOD、TND、IQD、LYD)。ISO 4217 が権威ある出典ですが、運用上の理由で逸脱するプロバイダーもあるため、決済プロバイダーの表も確認してください。
丸めるべきは為替レートですか、換算後の金額ですか?
換算後の金額だけです。レートはプロバイダーが返すフル精度のまま保持し、掛けたうえで、結果を対象通貨の補助単位に 1 回だけ丸めます。掛ける前にレートを丸めると、取引規模に比例した誤差が入ります。
四捨五入と銀行家丸めの違いは?
四捨五入(half up)はちょうど半分の値を常にゼロから遠ざけます(2.5 → 3)。銀行家丸め(half even)は最も近い偶数桁へ送り(2.5 → 2、3.5 → 4)、多数の金額を集計する際の系統的な上方バイアスを取り除きます。顧客に見せる価格には half up、会計とレポーティングには half even を使ってください。
換算後の明細が換算後の合計に合わないのはなぜ?
各行を独立に丸め、誤差が積み上がるからです。合計を 1 回丸め、その合計を最大剰余法で各行に按分してください。そうすれば各部の和は構造上必ず全体に一致します。
金額を小数 2 桁の float で保存してはいけませんか?
いけません。2 進浮動小数点は 0.1 のような値を正確に表現できないため、加算や乗算で誤差が積み上がり、やがて丸めの判断を反転させます。補助単位の整数で保存するか、正確な 10 進型を使ってください。これは理論上の懸念ではなく、1 セントずれの照合失敗の最も一般的な根本原因です。
フル精度のレートを取得する
正しい丸めは、信頼できるレートと、捨てずに残した精度から始まります。無料の Finexly API キーを取得——クレジットカード不要。170 以上の通貨に対して月 1,000 リクエスト無料から始め、通貨コンバーターで計算を確認し、取引量が増えたら料金プランをご覧ください。
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →