关于 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)。
带类型的行
这三种模型都包含 Spree::TypedAdjustmentLine:双重所有者 cart_id / order_id(必须且只能有一个——读取 row.owner)、label、amount(以及 display_amount)和 metadata。
| 模型(前缀) | 关联对象(必须且只能有一个……) | 类型 | 正负 | 写入方 |
|---|---|---|---|---|
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(促销外键在删除时置空);税费行会保留rate、label、provider_id和辖区信息;关税费用会在metadata中保留其 HS 编码、原产地和费率。切勿根据当前商品目录重新计算已下单金额。 - 费用默认应税(税费行通过
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
这是有意设计的两阶段流程:调整器先持久化折扣后的基数,然后再据此估算税额。
触发条件——写入操作,而非步骤。 添加/更新/移除商品(通过 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 |
|---|---|---|
| 手动折扣(固定金额/百分比,单行或分摊) | 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 是全有或全无的:如果请求中的任何商品变更被拒绝,则不会保存任何行项目、数量或地址变更。
只有 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不包含关税)——如有进口增值税,由关税提供程序自身将其作为针对关税费用的税费行写入。 - 在费用的
metadata中快照输入值(HS 编码、原产地、税率);已下单订单的关税绝不可重新计算。 - 变体带有
hs_code、country_of_origin(制造地,不是发货地)和customs_description。
渲染总额(Store API / SDK)
金额是字符串("135.60"),并带有预先格式化的对应 display_* 字段("$135.60")。渲染 display_*;如必须进行计算,请使用十进制库或整数分(美分)——绝不要使用 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)。 - 礼品卡和商店余额是付款,不是折扣——它们会减少
amount_due,而不是total。 - 隐藏价格(受限 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