spree-order-totals について
spree-order-totals は、型付きの税、割引、手数料行からカートと注文の合計を計算する方法を解説する Spree 開発者向けスキルです。再計算、注文確定後の金額変更、カスタム調整処理、ストアフロントでの金額表示を扱います。
主な機能
- 01型付きの税、割引、手数料行
- 02カートと注文の合計計算式
- 03再計算ワークフローと実行条件
- 04注文確定後の金額固定
- 05手数料と割引用のカスタム調整処理
- 06Store APIの金額表示ガイド
活用シーン
- 01ストアフロントの合計表示の構築
- 02調整処理によるカスタム手数料の追加
- 03確定済み注文の割引や手数料の変更
- 04税額や割引合計の不一致の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-order-totalsインストール後、Claude Code で/spree-order-totalsを入力して読み込まれたか確認します。
詳細
Spree の注文合計
カートまたは注文の total は、型付き金額行から構成されます。多相の Spree::Adjustment はありません。金額の種類ごとに独自のテーブルと購入データ上の非正規化された合計値があり、それらの整合性を保つ単一のワークフローがあります。
5.x の調整データから移行する場合は、
spree-upgrade-5-to-6(spree:migrate_adjustments_to_typed_rows)を参照してください。
計算式
total = item_total + delivery_total + adjustment_total
adjustment_total = (all discount rows) + fee_total + additional_tax_total
| 列(カートと注文) | 内容 |
|---|---|
item_total | 明細行の amount の合計(価格 × 数量、割引・税引前) |
delivery_total | フルフィルメントの cost の合計(選択された配送レート) |
discount_total | プロモーション割引行の合計(マイナス)。手動割引は含まれません(「注意事項」を参照) |
fee_total | 手数料行の合計(常に 0 以上、関税を含む) |
additional_tax_total | included: false の税行の合計(上乗せ分) |
included_tax_total | included: true の税行の合計(価格にすでに含まれる税額。参考値) |
adjustment_total | 割引 + 手数料 + 追加税額 |
total | 顧客が支払う金額 |
payment_total | 入金済み支払い額から返金額を差し引いた金額 |
tax_total(メソッド) | included_tax_total + additional_tax_total |
amount_due(メソッド) | max(total − payment_total − applied store credit, 0) |
明細行とフルフィルメントには、それぞれ調整対象ごとの列(discount_total、included_tax_total、additional_tax_total、adjustment_total、taxable_adjustment_total=割引、non_taxable_adjustment_total=手数料、pre_tax_amount)もあります。そのため、行ごとの計算や返品の際に、行を再クエリする必要はありません。
確定済みのマーケットプレイス注文には、commission_amount_total / commission_tax_total / commission_total もあります。これらは販売者に請求されるもので、total には含まれません(spree-marketplace を参照)。
型付き行
3つのモデルはいずれも Spree::TypedAdjustmentLine を含みます。これは cart_id / order_id の所有者を持ち(いずれか一方のみ。row.owner を参照)、label、amount(および display_amount)、metadata を備えています。
| モデル(プレフィックス) | 紐付け先(いずれか1つのみ…) | 種類 | 符号 | 書き込み元 |
|---|---|---|---|---|
Spree::TaxLine(tl_) | 明細行、フルフィルメント、または手数料 | —(included は true/false、taxability_reason) | ≥ 0 | 税プロバイダーのみ(spree-taxes を参照) |
Spree::Discount(disc_) | 明細行またはフルフィルメント | promotion、manual | ≤ 0 | プロモーション調整機能、管理者(manual) |
Spree::Fee(fee_) | 明細行、フルフィルメント、またはいずれにも紐付かない(注文レベル) | surcharge、handling、gift_wrap、cod、payment、duty(拡張可能) | ≥ 0 | 調整機能、インテグレーション、管理者 |
- 注文レベルの割引行はありません。 注文全体に適用されるプロモーションや手動割引は、各行の割引後の基準額に基づき最大剰余法で明細行に配分されます。これにより、部分返品時に推測することなく適切な割合を返金できます。
- 行には由来のスナップショットが保存されます。割引行は
code、value、value_typeを保持します(プロモーションの外部キーは削除時に null 化されます)。税行はrate、label、provider_id、管轄区域を保持します。関税手数料は HS コード、原産地、税率をmetadataに保持します。確定済み注文の金額を、現在のカタログから再計算してはいけません。 - 手数料はデフォルトで課税対象です(税行は
fee_idを参照します)。duty手数料は課税対象から除外されます。 - クレジットをマイナスの手数料にしてはいけません。割引を作成してください。
Spree::Carts::RecalculateTotals — 唯一の境界
金額を変更するすべての処理は、このワークフロー(Spree.cart_recalculate_totals_workflow、注文側の対応するワークフローは Spree.order_recalculate_totals_workflow = Spree::Orders::RecalculateTotals)で完了します。cart.recalculate_totals! はショートカットです。
reset caches → total_quantity → item_total / delivery_total / payment_total
→ [unless money-frozen] regenerate rows:
Spree.adjusters.each { |a| a.adjust(cart) } # promotions, your fees/discounts
run_hooks :set_tax_line_context # carts.recalculate_totals.set_tax_line_context
cart.tax_provider.estimate(cart, **cart.tax_estimate_inputs, context:)
→ fold rows into per-line and purchase columns → total → one update_columns
設計上、2つのパスで処理します。まず調整項目が割引後の基準額を保存し、その後、その金額に基づいて税額を見積もります。
実行のきっかけ — ステップではなく書き込みです。 商品の追加/アップサート/削除(Spree::Carts::Recalculate 経由。配送候補の再構築とプロモーションの再有効化も行います)、住所またはマーケットの変更(recalculate_for_address_change! は商品の価格も再計算します)、配送レートの選択、クーポン/ギフトカード/ストアクレジットの適用/削除、支払い、カートのマージ、そして完了ロック内でもう一度実行されます。請求額が以前のリクエストの値のまま信頼されることはありません。カートを読み取るだけでは再計算されません(ただし、Store API の GET では、配送候補がない場合に補完されます)。
Store API の書き込みはすべて、最新の合計額を含むカート全体を返します。ストアフロントが「再計算」エンドポイントを呼び出すことはありません。
プロモーション調整項目では勝者のみを採用
Spree::Adjusters::Promotion は、再計算のたびに対象となるすべてのプロモーションを評価し、競合グループごとに勝者のみを保存します。明細レベルのアクションは商品明細ごと、フルフィルメントのアクションはフルフィルメントごと、注文レベルのアクションは注文全体で競合し(勝者の金額は明細に分配されます)。最もマイナスの金額が勝ち、同額の場合は新しいアクションが優先されます。敗れた候補は書き込まれないため、最良の候補になった時点で自動的に「復帰」します。このパスで書き込まなかった kind: 'promotion' の行も削除されます。詳細は spree-promotions をご覧ください。
注文確定後の金額の固定
確定済みの注文(completed?)、または resum_only: true(販売者ごとに分割された子ドラフト)を指定して呼び出された場合、ワークフローは行の再生成をスキップします。調整項目の処理、プロモーションの競合判定、税額の見積もりは行いません。注文にすでにある行の合計だけを再計算します。その結果、次のようになります。
- 今日のプロモーションや税率によって、先週の注文が書き換えられることはありません。
- 確定済みの注文では、カスタム調整項目は実行されません。
- 注文確定後の金額変更は、行を書き込んでから合計を再計算する明示的なサービスを通じて行います。
| 変更 | サービス | Admin API |
|---|---|---|
| 手動割引(定額/割合、1つの商品明細または分配) | Spree::Orders::Discounts::Create/Update/Destroy | POST/PATCH/DELETE /api/v3/admin/orders/:id/discounts |
| 手数料 | Spree::Orders::Fees::Create/Update/Destroy | /api/v3/admin/orders/:id/fees |
| 商品/属性 | Spree::Orders::Update(items: [...])、Orders::UpdateItem、Orders::AddItem | PATCH /api/v3/admin/orders/:id、/api/v3/admin/orders/:id/items |
| 税明細 | —(プロバイダーが書き込み) | GET /api/v3/admin/orders/:id/tax_lines(読み取り専用) |
Spree::Orders::Discounts::Create.call(order: order, label: 'Price match', value: '15', value_type: 'flat')
Spree::Orders::Fees::Create.call(order: order, attributes: { label: 'Handling', kind: 'handling', amount: 2.5 })
これらのサービスはそれぞれ合計を再計算してから order.update_statuses! を呼び出します。合計額の変更を通知するイベントがないためです。手数料が加算された支払い済みの注文は partially_paid となり、支払済み金額を下回る額に減額された注文は overcharged となります(amount_due はゼロを下回らないため、返金が必要であることはステータスでのみ示されます)。確定済みの注文の金額を変更するカスタムサービスも、同じ呼び出しを行う必要があります。Orders::Update は全件成功または全件失敗です。リクエスト内の商品変更が1つでも拒否されると、明細、数量、住所の変更は一切保存されません。
編集できるのは manual 割引のみです。promotion 行に手を加えると、API 経由では 422 エラーになります。確定済みの注文では税額が再見積もりされないため、手動割引によって請求済みの税額が減ることはありません。税額の修正は、税務プロバイダーまたは返品を通じて処理してください。
カスタム手数料と割引:調整処理
調整処理は一連の行を管理し、凍結されていない再計算のたびに再実行されます。契約は次のとおりです。Spree::Adjusters::Base をサブクラス化し、冪等な update を実装します(存在すべき行を書き込み、存在すべきでない行を削除します)。order はカート(または下書き注文)です。order.fees / order.discounts は適切な所有者に自動的に紐づきます。
# server/app/models/my_app/adjusters/gift_wrap.rb
module MyApp
module Adjusters
class GiftWrap < Spree::Adjusters::Base
PRICE = BigDecimal('4.99')
def update
wrapped = order.line_items.select { |li| li.metadata['gift_wrap'].present? }
order.fees.where(kind: 'gift_wrap').where.not(line_item_id: wrapped.map(&:id)).delete_all
wrapped.each do |li|
order.fees.find_or_initialize_by(kind: 'gift_wrap', line_item_id: li.id)
.update!(label: 'Gift wrapping', amount: PRICE * li.quantity)
end
end
end
end
end
# server/config/initializers/spree.rb — MUST be after_initialize:
# core assigns Spree.adjusters = [Spree::Adjusters::Promotion] in its own after_initialize,
# which would wipe anything appended earlier.
Rails.application.config.after_initialize do
Spree.adjusters << MyApp::Adjusters::GiftWrap
Spree::Fee::KINDS << 'insurance' unless Spree::Fee::KINDS.include?('insurance') # custom fee kind (validated, not frozen)
end
調整処理から作成するカスタム割引には、kind: 'manual' を指定する必要があります(Spree::Discount::KINDS は promotion / manual に固定されています)。また、ラインアイテムまたはフルフィルメントに紐づけてください。kind: 'promotion' の行は作成しないでください。プロモーション調整処理が、自身で作成していない行を削除します。
調整処理が作成する手数料には、同じ処理内で税が適用されます(プロバイダーは調整処理の後に実行されます)。
関税
関税は kind: 'duty' の Spree::Fee で、着地原価の連携機能(通常は調整処理)によって作成されます。ルールは次のとおりです。
- デフォルトの課税対象には含まれません(
purchase.taxable_itemsでは関税が除外されます)。輸入 VAT がある場合は、関税プロバイダー自身が関税手数料に対する税行として作成します。 - 入力値(HS コード、原産国、税率)を手数料の
metadataにスナップショットとして保存します。確定済み注文の関税は再計算しないでください。 - バリアントには
hs_code、country_of_origin(出荷元ではなく製造国)、customs_descriptionが含まれます。
合計の表示(Store API / SDK)
金額は文字列("135.60")と、書式設定済みの対応する display_*("$135.60")で表されます。display_* を表示してください。計算が必要な場合は、10 進数ライブラリまたは整数のセントを使用し、JS の浮動小数点数は決して使用しないでください。
cart.display_item_total // items
cart.display_discount_total // negative
cart.display_delivery_total
cart.display_fee_total // cart.fees[] for line-by-line (label, kind, display_amount)
cart.display_tax_total // safe single tax line (included + additional)
cart.display_total
cart.display_amount_due // after gift cards / store credit
- 独自に小計を計算する場合は、
additional_tax_totalのみを加算してください。included_tax_totalはすでに価格に含まれているため、加算しないでください。これは欧州のストアフロントで最もよくあるバグです。 - 小計と合計の間に手数料を表示してください。手数料はすでに
totalに含まれているため、二重に加算しないでください。duty手数料は専用のラベルで表示します。 - Store API の
cart.discounts/order.discountsは、適用されたプロモーションごとの概要(name、code、amount、display_amount、promotion_id)であり、型付きのSpree::Discount行ではありません。型付きの行は Admin API(adminClient.orders.discounts)にあります。 - ギフトカードとストアクレジットは割引ではなく支払いです。これらは
totalではなくamount_dueを減らします。 - 価格を非表示にする場合(B2B のゲストを制限する場合)、金額属性は
nullとしてシリアライズされます。
注意点
discount_totalに含まれるのはpromotion行のみです。 手動割引(およびカスタム調整処理による割引)はadjustment_totalとtotalには含まれますが、discount_totalには含まれません。概要でdiscount_totalとtotalを表示すると、手動割引が欠落した金額のように見えます。「割引」にはitem_total + delivery_total + fee_total + additional_tax_total − totalを表示するか、Spree::Discount行を一覧表示してください。- 合計のカラムを直接書き換えないでください(
update_column(:total, …))。次の再計算で上書きされます。行を書き込み、その後recalculate_totals!/注文サービスを呼び出してください。 TaxLine行を手動で作成しないでください。 プロバイダーによってアイテムごとに全件置き換えられるため、次回の見積もり時に消失します。- アイテムを変更するとギフトカードとチェックアウト時のストアクレジットが適用解除されます(
Spree::Carts::Recalculate)。編集後に再適用してください。一方、合計の単純な再計算(配送、割引、税額の変更)ではギフトカードが維持され、新しい合計に合わせてその支払い額が調整されます。 - 調整処理で例外が発生すると、再計算全体(および顧客のカートへの追加)が中断されます。調整処理内で外部呼び出しを行わないか、内部で例外を処理してください。
- チェックアウト中に
row.orderを読み取らないでください。row.ownerを使用してください。
さらに詳しく読む
node_modules/@spree/docs/dist/developer/core-concepts/order-totals.md、discounts.md、fees.mdnode_modules/@spree/docs/dist/developer/how-to/custom-promotion.md— カスタム調整処理- 関連スキル:
spree-taxes、spree-promotions、spree-checkout、spree-returns、spree-pricing - ソース:
spree/core/app/workflows/spree/carts/recalculate_totals.rb、spree/core/app/models/spree/adjusters/、spree/core/app/models/spree/{tax_line,discount,fee}.rb