Vendure Money 単位運用¶
目的¶
Vendure は Money scalar を整数で扱う。Ritsubi では Vendure の DefaultMoneyStrategy.precision = 2 を前提に、JPY でも Vendure Money 値を 円の 100 倍整数として扱うため、業務設定や外部連携の円単位と混在すると、送料 900円 が Storefront で ¥9 と表示されるような不具合につながる。
このページを、円単位と Vendure Money 値の境界変換に関する実装上の正本とする。
Vendure と JPY の関係¶
Vendure の CurrencyCode.JPY は「通貨コードが日本円である」ことを表すが、標準構成の Money 数値を自動的に円単位へ変えるものではない。Ritsubi は Vendure 標準の DefaultMoneyStrategy.precision = 2 を前提にしているため、JPY でも GraphQL Money scalar、ProductVariant.price、Order.totalWithTax などは 100 倍整数として扱う。
例:
| 意味 | Vendure Money 値 | 円単位 |
|---|---|---|
| 900円 | 90_000 |
900 |
| 20,000円 | 2_000_000 |
20000 |
| -300円調整 | -30_000 |
-300 |
MoneyStrategy を独自実装して precision を変えることは Vendure の拡張点としては可能だが、既存の ProductVariant / Order / Payment / ShippingLine / SMILE / SB Payment / Storefront 表示まで全金額データの単位前提が変わる。Ritsubi では既存データと外部連携の安全性を優先し、DefaultMoneyStrategy.precision = 2 のまま、境界変換を helper で明示する。
なお、Vendure 標準の Money field ではなく JSON 内に持つ金額も同じ方針を適用する。たとえば CommercialRule.tiers[].actions[].value は JSON だが、set_unit_price / add_unit_amount / add_order_amount では Vendure Money 値として保存・評価する。
単位の定義¶
| 単位 | branded type | 例 | 用途 |
|---|---|---|---|
| 円単位 | YenAmount |
900 |
業務設定、SMILE、GA4、送料しきい値、表示前の金額 |
| Vendure Money 値 | VendureMoney |
90_000 |
Vendure API、Order/Line/Shipping の Money field |
| 表示用フォーマット後 | string |
"¥900" |
Storefront / Dashboard / email の表示文字列 |
0 は円単位でも Vendure Money 値でも同じ数値だが、非ゼロ金額と同じ field に入れる場合は、その field の単位に合わせて helper を通す。これにより、後から 0 を非ゼロ値へ変えたときの単位漏れを防ぐ。
実装 helper の正本は @ritsubi/utils の packages/utils/src/currency-helpers.ts。
import {
asVendureMoney,
asYenAmount,
formatVendurePriceValue,
toVendureMoneyFromYen,
toYenFromVendureMoney,
type VendureMoney,
type YenAmount,
} from "@ritsubi/utils";
変換ルール¶
toVendureMoneyFromYen(900); // 90_000
toYenFromVendureMoney(90_000); // 900
formatVendurePriceValue(90_000); // "¥900"
- 円から Vendure Money 値へ渡す境界では
toVendureMoneyFromYen()を使う。 - Vendure Money 値を業務ロジック、外部連携、分析、表示前の金額へ戻す境界では
toYenFromVendureMoney()を使う。 - Storefront 表示では
formatVendurePriceValue()を使う。 - 外部入力や GraphQL 型など、型だけでは単位を表現できない境界では
asYenAmount()/asVendureMoney()で意図を明示する。 - Vendure の
Moneyfield へ非ゼロ数値リテラルを直接入れない。seed / fallback / mock 表示でも、値の意味が円ならtoVendureMoneyFromYen()を使う。
禁止事項¶
金額文脈での直書き変換は禁止。
// NG
const shipping = /* shippingFeeYen multiplied by 100 */;
const displayAmount = /* order.totalWithTax divided by 100 */;
// OK
const shipping = toVendureMoneyFromYen(shippingFeeYen);
const displayAmount = toYenFromVendureMoney(order.totalWithTax);
Vendure 標準の roundMoney は Vendure Money 値同士の計算結果を丸めるための helper としてのみ使う。円単位と Vendure Money 値の境界変換には使わない。
* 100 / / 100 だけでなく、Vendure Money 値を変換せずに表示・外部出力するのも禁止。次はいずれも sen を 100 倍金額のまま出してしまう典型的な漏れ(過去に帳票 / SMILE / SBPS / Dashboard で発生):
// NG: sen を変換せず表示・外部出力する
`¥${order.totalWithTax.toLocaleString("ja-JP")}`;
`${payment.amount.toLocaleString("ja-JP")} 円`;
new Intl.NumberFormat("ja-JP", { style: "currency", currency }).format(refund.total);
Handlebars.registerHelper("yen", (v) => Number(v).toLocaleString("ja-JP")); // 帳票
csvRow.push(line.proratedLinePrice); // 外部 CSV へ sen 直書き
// OK: 境界で必ず円へ変換する
formatVendurePriceValue(order.totalWithTax); // "¥..."
`${Math.round(toYenFromVendureMoney(payment.amount)).toLocaleString("ja-JP")} 円`;
csvRow.push(Math.round(toYenFromVendureMoney(line.proratedLinePrice)));
Dashboard の金額表示は formatVendurePriceValue を正本にし、formatYen / formatMoney / formatPrice といった独自フォーマッタを画面ごとに作らない(同じ SMILE 単価が画面によって正しく円表示される実装と 100 倍表示される実装に分裂した実績がある)。
主要境界¶
| 境界 / field | 入力の単位 | 出力の単位 | helper / 方針 |
|---|---|---|---|
Vendure ProductVariant.price / priceWithTax / listPrice |
円設定・SMILE単価 | Vendure Money | toVendureMoneyFromYen() |
Vendure Order.totalWithTax / subTotalWithTax / ShippingLine.price |
Vendure 内部計算 | Vendure Money | 変換しない |
Vendure Payment.amount / Refund.amount / Surcharge.priceWithTax |
Vendure 内部計算 | Vendure Money | 変換しない |
Vendure GraphQL Money scalar |
Vendure Money | Vendure Money | client 側表示で変換 |
ProvinceDeliveryProfile 送料設定 |
円 | Vendure Money | toVendureMoneyFromYen() |
| 送料無料しきい値判定 | Vendure Money 小計 | 円しきい値比較 | toYenFromVendureMoney() |
| 送料無料進捗 API | 円しきい値/残額 | Vendure Money | toVendureMoneyFromYen() |
| ポイント・ギフト券利用 | 1ポイント=1円 | Vendure Money | toVendureMoneyFromYen() |
| Storefront / Dashboard / email 価格表示 | Vendure Money | ¥... |
formatVendurePriceValue() |
| 帳票 (請求書 / 見積 PDF) の金額 | Vendure Money | 整数円 | report-render の yen helper (sen→円) |
| Dashboard SMILE 単価マスタ / SBPS 決済額・返金 / 仮想商品 表示 | Vendure Money | ¥... |
formatVendurePriceValue() |
| Dashboard SBPS 部分返金フォーム入力 | 円入力 | Vendure Money | 送信境界で toVendureMoneyFromYen() |
| 運用診断 (diagnostics) finding の金額 | Vendure Money | 整数円 | finding-helpers の formatYen (sen→円) |
| Dashboard custom field 入力 (sen 保存) | Vendure Money | MoneyInput 円表示 | ritsubi.sen-money-input |
| Dashboard custom field 入力 (円整数保存) | 円 | MoneyInput 円表示 | ritsubi.yen-money-input |
| CommercialRule 金額 action | React Dashboard 円入力 | Vendure Money | 保存境界で toVendureMoneyFromYen() 相当 |
| CommercialRule 小計条件 | React Dashboard 円入力 | Vendure Money | 保存境界で toVendureMoneyFromYen() 相当 |
| CommercialRule シミュレーション表示 | Vendure Money | ¥... |
Dashboard 表示境界で円へ戻す |
| SMILE 価格 import | 円 | Vendure Money | toVendureMoneyFromYen() |
| SMILE 受注・CSV export | Vendure Money | 円 | toYenFromVendureMoney() |
SB Payment hosted checkout / XML API amount |
Vendure Payment.amount |
整数円 | toYenFromVendureMoney() |
SB Payment callback amount 検証 |
整数円 | Vendure Money比較 | 円へ戻して照合 |
| GA4 ecommerce payload | Vendure Money | 円 number | toYenFromVendureMoney() |
CommercialRule 金額 action は set_unit_price / add_unit_amount / add_order_amount。CommercialRule 小計条件は conditions.order.matchedSubtotalNet / cartSubtotalNet。
金額 helper の対象外¶
以下は金額ではないため、Vendure Money helper を使わない。
- 税率、割引率、掛率、ポイント付与率などの ratio / percent
- 個数、入数、購入単位、在庫数
- 重量、サイズ、配送リードタイム、日時、timeout / TTL
taxRateなど Vendure が率として扱う field
送料・送料無料の例¶
送料ルールが 900円 の場合、Vendure の shipping calculator は 90_000 を返す。Storefront は Vendure Money 値を formatVendurePriceValue() で表示するため、画面上は ¥900 になる。
送料無料しきい値 20,000円 は Vendure Money 値では 2_000_000。残り 108円 の進捗表示は Shop API では 10_800 として返し、Storefront で ¥108 と表示する。
CommercialRule の例¶
React Dashboard で固定単価 1,234円、単価調整 -50円、注文調整 -300円 を入力した場合、保存される JSON は Vendure Money 値になる。
{
"actions": [
{ "kind": "set_unit_price", "value": 123400 },
{ "kind": "add_unit_amount", "value": -5000 },
{ "kind": "add_order_amount", "value": -30000 },
{ "kind": "multiply_unit_price", "value": 0.9 }
]
}
multiply_unit_price は倍率なので変換しない。conditions.order.matchedSubtotalNet / cartSubtotalNet も同じく、Dashboard では円入力、保存・評価では Vendure Money 値とする。
再発防止¶
金額文脈の * 100 / / 100 と、production path の Vendure Money field への非ゼロ裸リテラルは scripts/quality/check-vendure-money-units.mjs で検出する。通常の root lint に含まれている。
pnpm run lint:vendure-money-units
pnpm run test:quality
float 乗算の精度注意点¶
Vendure Money 値(整数)を 1.1 など浮動小数点数で乗算すると精度誤差が生じる。
// ❌ Bad: float 乗算は精度誤差を生む
1_600_000 * 1.1; // → 1_760_000.0000000002
Math.ceil(toYenFromVendureMoney(1_760_000.0000000002)); // → +1 円ぶれ
// ✅ Good: 整数算術で精度誤差を回避(標準税率 10% の場合)
Math.round((1_600_000 * 11) / 10); // → 1_760_000(正確)
税率乗算が必要な場合は 整数算術(× 11 ÷ 10) または Math.round を組み合わせて使うこと。
特に Math.ceil で円単位に丸める直前の金額に float 乗算が入ると、+1 円ぶれが表示に出る。
実例: FreeShippingProgressService.applyStandardTaxMultiplier (packages/plugins)。
単位 helper の回帰テストは以下で確認する。
pnpm --filter @ritsubi/utils test
pnpm --filter ritsubi-storefront exec vitest run src/lib/analytics/ga4.test.ts src/lib/free-shipping-progress.test.ts src/lib/utils.test.ts
pnpm --filter @ritsubi/plugins exec vitest run src/rule-engine/shipping/services/free-shipping-progress.service.spec.ts src/rule-engine/shipping/services/shipping-calculator.service.spec.ts
関連実装¶
packages/utils/src/currency-helpers.tspackages/plugins/src/rule-engine/shipping/services/shipping-calculator.service.tspackages/plugins/src/rule-engine/shipping/services/free-shipping-progress.service.tspackages/plugins/src/standard-extensions/admin-extensions/dashboard/campaigns/campaign-builder-ops.tspackages/plugins/src/standard-extensions/admin-extensions/dashboard/campaigns/campaign-formatters.tsapps/storefront/src/lib/storefront-formatters.tsapps/storefront/src/lib/analytics/ga4.helpers.ts