コンテンツにスキップ

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.priceOrder.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/utilspackages/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 の Money field へ非ゼロ数値リテラルを直接入れない。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 標準の roundMoneyVendure 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-renderyen helper (sen→円)
Dashboard SMILE 単価マスタ / SBPS 決済額・返金 / 仮想商品 表示 Vendure Money ¥... formatVendurePriceValue()
Dashboard SBPS 部分返金フォーム入力 円入力 Vendure Money 送信境界で toVendureMoneyFromYen()
運用診断 (diagnostics) finding の金額 Vendure Money 整数円 finding-helpersformatYen (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.ts
  • packages/plugins/src/rule-engine/shipping/services/shipping-calculator.service.ts
  • packages/plugins/src/rule-engine/shipping/services/free-shipping-progress.service.ts
  • packages/plugins/src/standard-extensions/admin-extensions/dashboard/campaigns/campaign-builder-ops.ts
  • packages/plugins/src/standard-extensions/admin-extensions/dashboard/campaigns/campaign-formatters.ts
  • apps/storefront/src/lib/storefront-formatters.ts
  • apps/storefront/src/lib/analytics/ga4.helpers.ts