はじめに
B2B コマース(拡張 LWR のストア)で日本向けの販売サイトを作ると、早い段階で次の壁に当たります。
- 標準税率 10% の商品(文房具など)と、軽減税率 8% の商品(飲食料品など)を、1つのカートに入れたい
- ところが標準の「手動の税」を設定すると、カートの全商品が同じ率で計算される
この記事では、この問題を 税コード + 税の拡張(Apex) で解決する手順を、実際に動かして確かめた結果と一緒にまとめます。円の端数で引っかかる点も最後に書きます。
対象読者
- 拡張 LWR の B2B コマースのストアで、日本の消費税を扱う必要がある管理者・開発者
- Apex の基本(クラスを書いて反映できる)と、ストアの基本設定は分かる方
対象外
- Aura のストア(Spring '23 より前にコマースを有効にした org だけで作れる、古い型のストアです。税はチェックアウトのフローの中で、税の連携の Apex クラス(
sfdc_checkout.CartTaxCalculationsを実装したもの)を呼ぶ別の仕組みです。この記事では扱いません) - Stripe 経由の Salesforce Tax Solution を使う構成
- インボイス制度(適格請求書)の記載要件、税務上の判断(端数処理の方法は事業者が決めることです)
検証の環境
Developer Edition から作ったスクラッチ org で、Salesforce CLI と Claude Code を道具に使って検証しました。数値は、手計算した期待値と実際の画面・データを突き合わせたものです。
検証は2回に分けています。1回目は通貨が米ドルの org、2回目は通貨が円の org です。結果の節もこの2つに分けて書きます。
なぜ「手動の税」ではできないのか
B2B コマースの税には道がいくつかあります。公式ヘルプ「Set Up Taxes in B2B Commerce」では、大きく次の3つです。
- Salesforce Tax Solution(Stripe を使う自動計算)
- 税の拡張・計算サービス(Avalara や Vertex などを Apex の拡張でつなぐ)
- 手動の Salesforce Tax Solution
自前で用意しやすいのは「手動」ですが、これには次の制約があります。
- カスタムのチェックアウトのストアだけ
- 総額の課税(税込み価格 = gross)だけ
- 国・州の単位の税率
さらに、商品ごとに別の税の決まり(Tax Policy)を付けられるのは、Stripe か拡張を使うストアだけです(ヘルプ「Assign a Tax Policy to a Product」)。手動のときは、ストアの既定の決まりが全商品に使われます。
実際に確かめた
軽減税率の材料(Tax Policy・Tax Treatment・税コード・8% の Tax Rate)を作り、お茶の Product2.TaxPolicyId に付けました。手動の税のままカートに入れると、結果は次のとおりでした。
- 商品の
TaxPolicyIdは保存できる(エラーにならない) - でも計算では使われない。ペンもお茶も、ストアの既定の「標準 10%」で計算された
円の org で確かめると、ペン10本(1,200円)とお茶10本(2,160円)のカートは、全部 10% で税 305 円になりました(8% が効いていれば 269 円のはずです)。エラーが出ないので、気づかずに公開してしまう危険があります。
公式の道: 税コード + 税の拡張
開発者ガイド「Salesforce Commerce Tax API」には、日本の消費税(JCT)の説明があります。
- 税の区分(tax class)で 10% と 8%(一部の食品・飲み物・新聞)を分ける
- 丸めは商品ごとではなく、区分ごとの合計で行う
これを踏まえると、日本向けの道は次の形になります。
| 役割 | 何で持つか |
|---|---|
| 商品ごとの税の区分 |
Product2.TaxPolicyId(Tax Policy → 既定の Tax Treatment → 税コード) |
| 税コードごとの率 |
TaxRate(10 や 8) |
| 計算 | Apex の税の拡張 |
税の拡張は2種類あります(開発者ガイド「Available Extensions」)。
-
commercestoretax.TaxService(拡張ポイントCommerce_Domain_Tax_Service) -
CartExtension.TaxCartCalculator(拡張ポイントCommerce_Domain_Tax_CartCalculator)
公式の見本(GitHub の forcedotcom/commerce-extensibility、commerce/domain/tax/)にも「まず TaxService で足りるかを確かめる」とあります。今回も TaxService で足りました。見本は「税コード → 率」を引く形なので、そのまま日本の消費税に合います。
手順とコード
1. 軽減税率の材料を作る
手動の税で標準 10% を作る手順と同じものを、もう1組作ります。順番は次のとおりです。
- Tax Policy を Draft で作る
- Tax Treatment を作る(税コード、例
JCT8。Legal Entity と Tax Engine は標準の側と同じにする) - Tax Rate を作る(税コード・州・率 8)
- Tax Policy を Active にする
- 軽減税率の商品の
Product2.TaxPolicyIdにこの Tax Policy を付ける
2. Apex の拡張を書く
commercestoretax.TaxService を継承し、processCalculateTaxes を上書きします。全体は長いので、要点だけ抜粋します(クラス名などは記事用に短くしています)。
public virtual with sharing class JapanTaxService extends commercestoretax.TaxService {
public override commercestoretax.CalculateTaxesResponse processCalculateTaxes(
commercestoretax.CalculateTaxesRequest request) {
String storeId = request.getWebstoreId();
Boolean gross = isGross(storeId); // ストアの DefaultTaxLocaleType が Gross か
commercestoretax.CalculateTaxesResponse response =
new commercestoretax.CalculateTaxesResponse(
gross ? commercestoretax.TaxLocaleType.Gross : commercestoretax.TaxLocaleType.Net);
// 明細を税コードごとに分け、率は TaxRate から引く(Apex に 10 や 8 を書かない)
// 区分ごとの合計で丸め、ずれは区分の中で一番大きい明細に寄せる
// ...
for (/* 明細 */) {
commercestoretax.CalculateTaxesResponseLineItem out =
new commercestoretax.CalculateTaxesResponseLineItem();
out.setLineId(li.getLineId());
out.setProductId(li.getProductId());
out.setTotalLineTaxAmount(t);
out.setTotalPriceTaxAmount(priceTax);
out.setTotalAdjustmentTaxAmount(t - priceTax);
out.setTotalTieredAdjustmentTaxAmount(0);
out.addTaxInfo(new commercestoretax.TaxInfo(
ship.getCountry(), ship.getState(), 1, (Double) rate,
'JCT ' + rate.stripTrailingZeros().toPlainString() + '%', code, t));
response.addCalculateTaxesResponseLineItem(out);
}
response.setTotalTaxAmount(grandTax);
return response;
}
// 内税: 税込み × 率 / (100 + 率)、外税: 税抜き × 率 / 100(丸める前)
private static Decimal taxOf(Decimal amount, Decimal rate, Boolean gross) {
return gross ? amount * rate / (100 + rate) : amount * rate / 100;
}
}
実際に動かして分かった、効いたポイントを挙げます。
- 税コードの決め方: 明細に税コードが入っていればそれを使い、無ければ「商品の Tax Policy → 既定の Tax Treatment の税コード」、それも無ければ「ストアの既定の Tax Policy の税コード」の順にしました。
- 率が無い税コードは例外にする: 黙って 0 円にしないためです。例外を投げると、支払いが ValidationError になり、注文は作られませんでした。
-
TaxRateはwithout sharingの内部クラスで読む: 画面では購入者のユーザーで拡張が動きます。with sharingのクラスでTaxRateを読むと購入者には 0 件に見え、No tax rate for tax codeが購入者の画面に出ました。管理者の API だけで確かめていると見つかりません。 -
内税と外税は要求に入っていない:
request.getWebstoreId()でWebStore.DefaultTaxLocaleType(Gross / Net)を引いて切り替えます。ストアの値を変えるだけで、外税の合計は「税抜き + 税」になりました。 -
値引きのあとの額に課税する: 明細の
getTotalPrice()は値引き前で、getAdjustments()の各getAmount()はマイナスの値で入ります。足してから課税しました(ヘルプ「Taxing and Promotion Adjustments」に沿う)。 -
setTotalTieredAdjustmentTaxAmount(0)を入れる: 税の計算 API(/commerce/webstores/{id}/taxes/actions/calculate-taxes)を叩くときだけ、この欄が無いとUNKNOWN_EXCEPTIONになりました。カート・チェックアウトの画面では気づかない落とし穴です。
3. テストを書く
公式(開発者ガイド「Write Tests for an Extension Provider」「Mock the Base Apex Class」)に従い、次のようにしました。
- カバー率は 75% 以上にする
- 別の名前空間のクラスは mock できないので、照会(
WebStoreやTaxRateの SOQL)を仮想メソッドに分け、テストの内側のクラスで上書きする - DML が 0 件であることを確かめる
今回のクラスは 9 本のテストで全て Pass、カバー率は 95% でした。
4. 登録してストアにつなぐ
登録は RegisteredExternalService、ストアへのつなぎは StoreIntegratedService です。画面でつなぐ必要はなく、データ(または CLI・API)で作れました。
RegisteredExternalService:
| 項目 | 値 |
|---|---|
| DeveloperName / MasterLabel | 任意の名前 |
| ExternalServiceProviderId | 上の Apex クラスの Id |
| ExternalServiceProviderType | Extension |
| ExtensionPointName | Commerce_Domain_Tax_Service |
StoreIntegratedService:
| 項目 | 値 |
|---|---|
| StoreId | ストアの Id |
| Integration | 上の RegisteredExternalService の Id |
| ServiceProviderType | Extension |
つながると、ストアの Settings > Checkout > Taxes タブの「Custom Providers」にある「Tax - Tax Service」の枠に Apex 名が出ます。Settings > Store の Extensions タブには税の札が無い(在庫・販促など6つだけ)ので、探す場所に注意してください。
画面から外すときは、同じ枠の ▼ メニューの Remove です。外すと StoreIntegratedService が 0 件になり、標準の税に戻ります。
確かめた結果(1回目: 米ドルの org)
カートの金額
標準 10% のペン 12.00 と軽減 8% のお茶 21.60 を、同じカートに入れました。
| 構成 | ペンの税 | お茶の税 | 合計 |
|---|---|---|---|
| 手動の税のまま(お茶に軽減の Tax Policy を付けても) | 1.09(10%) | 1.96(10%) | 3.05 |
| 税の拡張 | 1.09(JCT 10%) | 1.60(JCT 8%) | 2.69 |
注文
発注書番号で注文を確定し、注文は Activated になりました。注文の明細の税(OrderItemTaxLineItem)も 10% と 8% に分かれて残りました。
確かめた結果(2回目: 円の org)
カートの金額と注文
ペン10本(1,200円・10%)とお茶10本(2,160円・8%)で、期待値は手計算で出しておきます。
- 内税で区分ごと・切り捨て: 10% は 1,200 × 10 / 110 = 109.09 → 109、8% は 2,160 × 8 / 108 = 160.00 → 160。合計 269
拡張をつないだカートも 269(109 + 160)になり、注文まで通りました。手動の税のままでは 305(全部 10%)でした。
確かめるときの注意
-
チェックアウト中のカートは、届け先を送り直しても税を計算し直しません。同じ住所・別の住所の Id・番地だけ違う住所の3通りで試しても、
CartTaxの日時も Apex のログも変わりませんでした。拡張をつないだあとは、新しいカートで確かめてください。 - 購入者1人に開けるカートは1つまでです(
A new cart can be created only when there are no current carts.)。 - 拡張が例外を投げても、チェックアウトの状態は Completed・税 0.00 と返ることがありました。状態だけを見ずに、
CartValidationOutputも確認してください。 -
商品の税率の API(GET
/commerce/webstores/{id}/taxes/products/{商品})は、拡張がつながっていても手動の税の率を返します。拡張は計算だけを上書きしているためです。軽減 8% の商品でも 10% と返りました。商品ページに税率を出す設計だと、この API は使えません。
円の端数の注意(2回目: 円の org)
円のストアでは、端数まわりで3つ引っかかりました。どれも手計算で先に期待値を出し、実際の値と突き合わせています。
1. 丸める単位で合計が変わる
ペン5本(600円)・ノート4冊(1,000円)・お茶10本(2,160円)のカートで、内税・切り捨てのとき、次のとおりです。
| 丸める単位 | 計算 | 合計の税 |
|---|---|---|
| 区分ごと(公式の説明どおり) | 10%: 1,600 × 10 / 110 = 145.45 → 145、8%: 160 | 305 |
| 明細ごと | ペン 54、ノート 90、お茶 160 | 304 |
外税・区分ごと・四捨五入にすると 333(10% が 160、8% が 172.8 → 173)になりました。内税か外税か、丸める単位、切り捨てか四捨五入かは、事業者が決めることです。この拡張では、丸める単位・丸め方・小数の桁をカスタムメタデータに出し、行を反映し直すだけで切り替えられるようにしました。Apex を直さずに切り替わることは、1回目(米ドルの org)で確かめています(同じカートで、区分ごと 2.94 ⇔ 明細ごと 2.93)。
2. 小数の桁を 2 のままにしない
円で拡張の小数の桁を 2 のままにすると、明細の税は 54.54 のように小数になり、カート合計の税は 305 と整数になって、明細の和(305.45)と合いません。円の案件では、拡張の設定の小数の桁を 0 にします。
3. 単価 × 数量 ≠ 合計
-
小数の単価: 1.5 円の価格は、円の org でもそのまま入りました。3個で 4.5 円ですが、カートの明細の合計は 5 に丸められ、見える単価は ¥2/item でした。裏のデータは
SalesPrice1.5・TotalPrice5 です。 -
段階価格(率で割引): ノート 250 円で 10 個以上 7% 引きにすると、単価は 232.5 になります。明細の合計は 2,325・税 211 で期待どおりでしたが、商品ページの段の表と、価格の API の
tierUnitPriceは ¥233、カートのUnitAdjustedPriceは 232 でした。見た目の単価 × 数量(233 × 10 = 2,330)と明細(2,325)が合いません。 -
段階価格を「額」で作ると合う: 20 冊以上を「1個 18 円引き」(
AdjustmentAmount)にすると、単価 232・合計 4,640・税 421(4,640 × 10 / 110 = 421.8 の切り捨て)で、表示も明細も期待どおりに揃いました。円の案件では、段階価格は率ではなく額で作ると表示のずれが出ません。 -
NetUnitPrice: カートのNetUnitPriceとOrderItem.UnitPriceは、円でも小数2桁で出ます(例: 109.1、手動の税では 109.09)。手動の税ではUnitPrice × 数量がTotalPriceと合いませんでした。帳票や外部連携で単価を使うなら、整数にするかを、要件で先に決めてください。
要件で先に聞くこと
- 内税か外税か(ストアの
DefaultTaxLocaleType) - 丸める単位(区分ごとか明細ごとか)、切り捨てか四捨五入か
- 税の内訳を画面に出すか(内税のストアでは、カートにも購入手続きにも税の行は出ず、小計・送料・合計だけでした。税そのものはカートの
TotalTaxAmountに入っています) - 帳票・連携で単価を使うか
まとめ
- 標準の手動の税では、商品に軽減の Tax Policy を付けても保存できるだけで、計算には使われない。エラーも出ないので気づきにくい
- 日本の消費税の公式の道は、商品ごとの Tax Policy(税コード)+ 税の拡張。
commercestoretax.TaxServiceで足りる - 率は
TaxRateから引き、Apex に書かない。TaxRateは購入者の画面で見えるようwithout sharingで読む - 拡張は
RegisteredExternalServiceとStoreIntegratedServiceで、データから登録できる - 確かめるときは新しいカートで。税の計算 API は
setTotalTieredAdjustmentTaxAmountが要る - 円では、丸める単位・小数の桁・段階価格の作り方で端数がずれる。先に手計算の期待値を出し、要件で決めておく
筆者は Salesforce の設定の代行・相談を受けています(連絡先は Qiita のプロフィールに)。
参考
- 開発者ガイド「Salesforce Commerce Tax API」 https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/b2b-d2c-comm-tax-apis.html
- ヘルプ「Add a Tax Calculation Service for Custom Checkout」 https://help.salesforce.com/s/articleView?id=commerce.comm_set_up_tax.htm&type=5
- ヘルプ「Set Up Taxes in B2B Commerce」 https://help.salesforce.com/s/articleView?id=commerce.comm_setuptaxes.htm&type=5
- ヘルプ「Assign a Tax Policy to a Product」 https://help.salesforce.com/s/articleView?id=commerce.comm_tax_assigntaxpolicytoproduct.htm&type=5
- ヘルプ「Taxing and Promotion Adjustments」 https://help.salesforce.com/s/articleView?id=commerce.comm_tax_with_adjustments.htm&type=5
- 開発者ガイド「B2B Commerce Checkout Flow (Aura)」 https://developer.salesforce.com/docs/atlas.en-us.b2b_b2c_comm_dev.meta/b2b_b2c_comm_dev/b2b_b2c_comm_setup_checkout_flow.htm
- ヘルプ「Create a Store」(Aura と LWR のテンプレート) https://help.salesforce.com/s/articleView?id=commerce.comm_createstore.htm&type=5
- 開発者ガイド「Available Extensions」 https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/available-extensions.html
- 開発者ガイド「Write Tests for an Extension Provider」 https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/write-tests-for-an-extension-provider.html
- 開発者ガイド「Mock the Base Apex Class」 https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/mock-the-base-apex-class.html
- 公式の見本: https://github.com/forcedotcom/commerce-extensibility (
commerce/domain/tax/)