mcpField
S
spree-order-totals

spree-order-totals

github.com/spree開発10104 回インストール4 週間前に更新
リポジトリ

spree-order-totals について

spree-order-totals は、型付きの税、割引、手数料行からカートと注文の合計を計算する方法を解説する Spree 開発者向けスキルです。再計算、注文確定後の金額変更、カスタム調整処理、ストアフロントでの金額表示を扱います。

主な機能

  1. 01型付きの税、割引、手数料行
  2. 02カートと注文の合計計算式
  3. 03再計算ワークフローと実行条件
  4. 04注文確定後の金額固定
  5. 05手数料と割引用のカスタム調整処理
  6. 06Store APIの金額表示ガイド

活用シーン

  1. 01ストアフロントの合計表示の構築
  2. 02調整処理によるカスタム手数料の追加
  3. 03確定済み注文の割引や手数料の変更
  4. 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_totalincluded: false の税行の合計(上乗せ分)
included_tax_totalincluded: 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/DestroyPOST/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::AddItemPATCH /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.md
  • node_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

コメント