关于 spree-checkout
面向开发者的 Spree 6 结账技能,介绍购物车要求、扩展方式,以及将购物车转换为订单的工作流,并说明结账扩展的实现细节与保障机制。
主要功能
- 01基于数据的购物车要求
- 02提示性的结账步骤
- 03自定义要求与步骤
- 04完成钩子与故障恢复
- 05购物车到订单的生命周期
适用场景
- 01为客户提交的信息添加结账要求
- 02自定义店铺的结账步骤
- 03处理订单完成时的重试与失败
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-checkout安装后在 Claude Code 中输入/spree-checkout确认已加载。
详细介绍
Spree 结账
Spree 6 中的结账是数据驱动的,而非状态机。Spree::Cart 可以按任意顺序收集商品、地址、配送和付款信息;购物车会报告仍缺少的内容(requirements);而且只有一道硬性门槛——Spree::Carts::Complete 工作流——会将其转换为不可变的 Spree::Order。
从 5.x 迁移而来(
order.next!、checkout_flow、state)?这些 API 已移除——请参阅spree-upgrade-5-to-6。
购物车与订单
Spree::Cart(cart_…) | Spree::Order(or_…) | |
|---|---|---|
| 阶段 | 购物 + 结账 | 已下单的购买(财务记录) |
| 生命周期标记 | 仅 completed_at — 没有状态列 | status(draft/placed/canceled)+ payment_status / fulfillment_status |
| 可变? | 是,直到完成;之后为只读(readonly? 会阻止所有写入) | 商品/价格已冻结;管理员编辑需通过明确的服务执行 |
| 结账信息 | requirements、checkout_steps、current_checkout_step、completed_checkout_steps | 无 — 已经下单 |
| 金额 | 每次重新计算都会重新生成各行记录 | 各行记录已冻结;仅重新汇总总额 |
贯穿两个阶段的记录——LineItem、Fulfillment、Payment、PaymentSession、StockReservation、TaxLine、Discount、Fee——都包含两个可空外键(cart_id + order_id,且恰好设置其中一个)。始终通过 #owner 读取父级:
line_item.owner # => Spree::Cart during checkout, Spree::Order after
line_item.order # nil during checkout — don't assume it's present
共享行为(地址、税务、商店积分、礼品卡、付款处理、货币/市场)位于 Spree::Purchase::* 关注点中,并由两个模型引入。请装饰该关注点(或使用钩子),而不要只装饰 Spree::Order。
order.cart 指向源购物车;spree_orders.cart_id 是唯一的——它是完成操作的幂等键。
要求:结账唯一强制执行的事项
每次读取购物车时,都会计算 Spree::Checkout::Requirements.new(cart).call → 一个 { step:, field:, code:, message: } 数组。根据 code 进行分支;message 是已翻译的文本;step 是分组提示。
内置提示性要求(每次读取购物车都会报告):
code | step | 触发条件 |
|---|---|---|
line_items_required | cart | 购物车为空 |
email_required | address | 没有电子邮件 |
ship_address_required | address | 有需要配送的商品,但没有地址 |
delivery_method_required | delivery | 某个履约项尚未选择配送方式 |
payment_required | payment | 有效付款未覆盖 amount_due_at_checkout |
po_number_required | address | 买方公司要求填写 PO 编号(使用员工密钥的购物车除外) |
order_minimum_not_met | cart | 未达到 B2B 订单最低金额 |
仅在完成时检查的项目(call(completion: true),在 Carts::Complete 内运行,因为它会加载所有商品项):out_of_stock、discontinued、quantity_rule_violated(均为 step: 'cart'),以及 guest_checkout_not_allowed。
requirements 数组为空,并不保证完成操作一定成功。 应将完成失败视为正常流程。
步骤仅供参考
cart.checkout_steps # => ["address", "delivery", "payment", "complete"]
cart.current_checkout_step # => step of the first unmet requirement ("cart" reports as "address")
cart.completed_checkout_steps # => steps before the current one
Store API 将这些步骤作为 current_step 和 completed_steps 暴露。内置步骤来自 Spree::Checkout::Registry.base_steps:
| 步骤 | 存在条件 |
|---|---|
address | 始终存在 |
delivery | cart.delivery_step_required?(有商品且并非全部为数字商品) |
payment | cart.payment_required?(总额大于零) |
confirm | cart.confirmation_required?(付款方式有此要求,或商店偏好设置有此要求) |
complete | 始终存在 |
服务器不会因步骤状态而拒绝写入——客户端可以按任意顺序设置电子邮件、地址、配送和付款信息。免费数字订单会报告 address → complete;不要在店面中将五个步骤写死。
自定义:Spree::Checkout::Registry
在 server/config/initializers/spree.rb 中配置。一项声明会同时提供给 requirements 数组和完成门控。
注册时机:Registry 是从 lib/ 加载的普通类级状态——核心代码启动时不会重置它(只有专为测试设计的 Registry.reset! 会重置),因此,在初始化器顶层调用或使用 Rails.application.config.after_initialize 块都可以。避免使用 config.to_prepare:它会在开发环境每次代码重新加载时运行,而 add_requirement / register_step 只会追加,不会去重,因此每次重新加载都会再添加一份相同的 requirement。
为现有步骤添加 requirement
# Require accepting terms before placing the order (stored in cart metadata,
# written by the storefront via PATCH /carts/:id { metadata: { terms_accepted: true } })
Spree::Checkout::Registry.add_requirement(
step: :payment,
field: :terms_accepted, # code becomes "terms_accepted_required"
message: 'You must accept the terms of sale',
satisfied: ->(cart) { ActiveModel::Type::Boolean.new.cast(cart.metadata['terms_accepted']) },
applicable: ->(cart) { cart.market&.name == 'Europe' } # optional, checked before satisfied:
)
- 新增 requirement 的
code始终由"#{field}_required"派生。 message会在启动时原样存储——此处的Spree.t调用只会在启动时的 locale 下解析一次。若要按请求翻译,请使用register_step(其requirements:lambda 会针对每个购物车运行)。Spree::Checkout::Registry.remove_requirement(step:, field:)会移除你(或扩展)注册的一项 requirement。
PO 编号已内置:在公司上设置 po_number_required,购物车就会报告 po_number_required(参见 spree-b2b)。请勿重复注册。
添加整个步骤
Spree::Checkout::Registry.register_step(
name: :loyalty,
before: :payment, # or after:; before: wins if both
applicable: ->(cart) { cart.customer.present? },
satisfied: ->(cart) { cart.metadata['loyalty_number'].present? },
requirements: ->(cart) {
[{ step: 'loyalty', field: 'loyalty_number', code: 'loyalty_number_missing',
message: Spree.t('loyalty.number_required', default: 'Enter your loyalty number') }]
}
)
未指定锚点的步骤(或购物车不存在的锚点,例如免费购物车上的 before: :payment)会插入到 complete 之前。调用 Registry.remove_step(:loyalty) 可撤销此操作。
重新排序或移除内置步骤
Spree::Checkout::Registry.base_steps.delete('confirm')
Spree::Checkout::Registry.base_steps['payment'] = ->(cart) { cart.total > 0 }
Spree::Checkout::Registry.base_step_names = %w[address payment delivery complete]
移除步骤只会移除其标签。归属于该步骤的 requirements 仍会阻止完成——移除 payment 并不会让未付款的购物车得以完成。
存储 requirement 检查的数据
Spree::Cart 有一个 metadata JSON 列,Store API 接受通过 PATCH /api/v3/store/carts/:id 传入的数据(会合并,而非替换)。购物车不包含 Spree::HasCustomFields。完成时,购物车的 metadata 会深拷贝到订单(如果结账拆分给多个卖家,也会拷贝到每个子订单和 OrderGroup),因此下单后 order.metadata['vat_number'] 仍会保留。此后两者相互独立——编辑其中一个不会改变另一个。**请将这些键视为客户输入:**购物者可通过 Store API 写入购物车 metadata,因此,切勿在其中保存代码之后会信任的内容(欺诈/审批标记、价格、权益)——这类数据应使用仅限员工访问的字段、自定义字段或你自己的列。
完成:Spree::Carts::Complete
result = Spree.carts_complete_workflow.call(cart: cart) # optional expected_total:, payment_pending:
if result.success?
order = result.value # Spree::Order — or Spree::OrderGroup when a multi-seller cart split
else
result.error.to_s # why: requirements, payment failure, hook rejection, cart_changed...
end
三个阶段,具有明确的事务边界:
- 准备:先在锁外执行重放检查和合同价格确认。随后,在
cart.with_lock中:并发完成保护 → 锁内重新计算(此时计算实际扣款总额,绝不信任先前请求中的值)→ 可选的expected_total偏差保护(cart_changed)→Requirements#call(completion: true)→carts.complete.validate钩子 → 设置completing_at→ 创建一个draft订单,复制商品行、配送方式 + 已选费率、TaxLine/Discount/Fee 行、促销、地址快照、税务标识和 PO 文档;重新指向付款、付款会话、预留和优惠券代码。 - 付款(
external_step,不在任何事务中):如果付款尚未完成,则处理付款。 - 最终确认:执行
carts.complete.before_finalize钩子 →Spree.order_complete_workflow(库存、draft → placed、状态、order.placed事件)→ 设置购物车的completed_at→ 将优惠券代码标记为已使用 →tax_provider.commit→ 执行carts.complete.after_finalize钩子。
你可以依赖的保证
- 幂等。 双击、重试,或支付 Webhook 与客户返回同时发生,都会返回同一个订单——重放步骤会返回
cart.order,唯一索引spree_orders.cart_id会捕获并发成功的请求(RecordNotUnique会重新进入并重放),而有效的completing_at声明(TTL 为 5 分钟)会返回completion_in_progress(API:409)。 - 崩溃恢复。 如果进程在订单提交后终止,下次尝试(或
Spree::Orders::FinalizeStaleDraftsJob)会重新运行 Finalize,而不会再次扣款。 - 扣款前失败会回滚。 如果在捕获任何款项之前支付失败,系统会销毁草稿订单,将付款、会话、预留和优惠券重新指向购物车,并清除
completing_at——客户可以修正问题后重试。 - 访客令牌会沿用。 订单会设置
token: cart.token,因此访客可以使用相同的X-Spree-Token读取订单。 - 总金额会在最后时刻重新计算,因此审核期间发生变化的价格或促销不会导致错误扣款。
结账流程钩子
在初始化程序中注册;处理程序使用类名字符串(可安全应对重载),并实现 call(workflow)。完整的钩子目录请参阅 spree-workflows。
| Key | Kind | Use for |
|---|---|---|
carts.add_item.validate | 校验 | 购买限制、资格检查——在线项目构建之前 |
carts.upsert_items.validate | 校验 | 数量编辑、移除和批量操作使用相同规则(注册这两个键) |
carts.add_item.after_item_added | 生命周期(在事务中) | 原子地写入相关记录 |
carts.complete.validate | 校验 | 最后时刻的否决(欺诈评分、外部信用检查)——任何款项移动之前 |
carts.complete.before_finalize | 生命周期 | 支付后运行;将数据复制到 workflow.order |
carts.complete.after_finalize | 生命周期 | 订单下单后 |
carts.merge.validate / after_merge | 校验 / 生命周期 | 合并策略 |
module MyApp
class CreditCheck
def call(workflow)
cart = workflow.cart
return unless cart.company && MyApp::Credit.on_hold?(cart.company) # your own service
workflow.reject!('Your account is on credit hold. Contact your account manager.')
end
end
end
Spree.hooks.register('carts.complete.validate', 'MyApp::CreditCheck')
绝不要在 before_finalize 或 after_finalize 中拒绝。 卡片已经扣款;拒绝会回滚数据库,但扣款仍然成立。应在 validate 中否决。对于客户可以修正的问题,优先使用 Registry requirement,而不是 validate 钩子——requirements 会显示在每次购物车读取结果中,而钩子只会在最后失败。
副作用(邮件、ERP 推送)应放在 order.placed 事件订阅器中,而不是钩子中(spree-events-webhooks)。
地址
Spree::Purchase::Addresses(Cart 和 Order 共用):
- 去重:
ship_address_attributes=/bill_address_attributes=会复用客户地址簿中完全相同的记录,而不是创建重复记录;访客只能编辑槽位中已有的记录。 - 设为默认地址: 已登录客户的结账地址会成为其默认收货/账单地址(钱包/快速结账地址除外)。
- 所有权保护:
ship_address_id=/bill_address_id=只接受来自客户本人地址簿(或其公司地址簿)的地址。其他地址会静默解析为nil——访客绝不能通过 ID 选择地址。 use_shipping: true会将收货地址复制到账单地址(收货地址为权威地址)。use_billing已弃用。- 地址字段:
first_name、last_name、address1、city、postal_code、country_code、state_code。 - 已登录客户的空白地址槽会自动填入已保存的默认地址。
更改地址会重新计算商品价格、重建配送方案并重新估算税费(cart.recalculate_for_address_change!)。
支付会话
会话限定于购物车:POST /api/v3/store/carts/:cart_id/payment_sessions 会先查找购物车,因此来自其他购物车的会话 ID 会返回 404。创建和更新操作会在购物车行锁内运行;complete 则有意不在网关调用期间持有锁——PaymentSession#settle_payment! 会在本地串行处理,因此,当确认操作与网关 webhook 竞争时,只会记录一次扣款。只有 complete 会处理已完成的购物车(webhook 可能已先完成结账);在已完成的购物车上创建会话会返回 404。参见 spree-payments。
Store API / SDK 结账流程
import { createClient } from '@spree/sdk'
const client = createClient({ baseUrl, publishableKey: 'pk_…' })
let cart = await client.carts.create()
const opts = { spreeToken: cart.token } // guests: persist cart.id + cart.token
await client.carts.items.create(cart.id, { variant_id: 'variant_…', quantity: 1 }, opts)
cart = await client.carts.update(cart.id, {
email: '[email protected]',
shipping_address: { first_name: 'Jane', last_name: 'Doe', address1: '1 Main St',
city: 'Austin', postal_code: '78701', country_code: 'US', state_code: 'TX' },
use_shipping: true,
}, opts)
for (const f of cart.fulfillments) {
await client.carts.fulfillments.update(cart.id, f.id,
{ selected_delivery_rate_id: f.delivery_rates[0].id }, opts)
}
const session = await client.carts.paymentSessions.create(cart.id, { payment_method_id: 'pm_…' }, opts)
// confirm with the provider's SDK (Stripe.js etc.), then:
await client.carts.paymentSessions.complete(cart.id, session.id, {}, opts)
cart = await client.carts.get(cart.id, opts)
if (cart.requirements.length) { /* route the user by requirements[0].step / code */ }
const result = await client.carts.complete(cart.id, opts) // Order | OrderGroup — narrow with isOrderGroup from @spree/sdk
每次写入都会返回包含最新总额和 requirements 的完整购物车——切勿单独调用“重新计算”。折扣码、礼品卡和商店余额(carts.discountCodes、carts.giftCards、carts.storeCredits)都可以在完成前随时应用。商店余额通过专用端点应用,且永远不会是 cart.payment_methods 之一。购物车只能使用礼品卡或商店余额,不能同时使用两者。配送、折扣或税费发生变化时,礼品卡抵扣金额会随总额调整。
登录:关联与合并
client.carts.associate(cartId, { token: jwt, spreeToken: cart.token })(PATCH /carts/:id/associate)会将访客购物车关联到已登录客户。它需要同时提供 JWT 和购物车令牌,只会关联访客购物车(或调用者自己的购物车),并会使用客户的默认信息填充邮箱和空白地址。- API 不会自动与客户的其他未完成购物车合并(
customer.carts.where(store: current_store)会列出这些Spree::Cart记录)。要合并两个购物车,请从你自己的端点/订阅程序调用Spree.cart_merge_workflow.call(cart: survivor, other_cart: guest_cart, customer:)(或survivor.merge!(guest_cart))。货币不匹配时会失败,并保留两个购物车。保留策略(保留较大的购物车、限制数量)应放在carts.merge.validate钩子中。
完成后
- 已完成的购物车为只读,且所有 Store API 购物车端点都会返回 404(购物车作用域为
incomplete)。通过client.orders.get(order.id, {}, { spreeToken })读取订单。 POST /carts/:id/complete是个例外:再次提交(使用购物车 ID 或返回的订单/组 ID)会返回现有订单。complete成功后,店面应删除已存储的购物车 ID/令牌。
故障排查:“为什么无法完成此购物车?”
- 读取
cart.requirements(spree console:Spree::Checkout::Requirements.new(cart).call(completion: true))。仅在完成时检查的集合还会加入库存/已停售/数量规则/访客策略相关失败项。 completion_in_progress/409→ 另一次尝试正在持有completing_at(TTL 为 5 分钟)。请重试;如果付款可能正在进行,切勿手动清除它。- 客户已付款,却出现
payment_required→ 礼品卡或商店余额已被移除:任何商品变更都会运行Spree::Carts::Recalculate,取消应用礼品卡和结账时使用的商店余额。编辑商品后请重新应用。 coupon_code_unavailable→ 购物车不再持有其单次使用批次码(其他购物车已取得该码,或某个订单已使用该码)。下次读取购物车时会发出警告并移除该码;购物者需要重新确认总额(参见spree-promotions)。- 没有配送方式且出现
delivery_method_required→ 地址没有匹配的配送区域;cart.warnings会为每件商品附带delivery_unavailable。 - 自定义要求始终无法满足 → 你的
satisfied:lambda 读取了店面从未写入的数据(例如购物车上的custom_fields——请使用metadata)。 - 被钩子拒绝 → 错误消息来自某个
carts.complete.validate处理程序中的workflow.reject!/workflow.errors;Spree.hooks.keys会列出已注册项。
测试
cart = create(:cart_ready_to_complete)
result = Spree.carts_complete_workflow.call(cart: cart)
expect(result).to be_success
expect(result.value).to be_placed
expect(Spree.carts_complete_workflow.call(cart: cart.reload).value).to eq(result.value) # replay
# Registry state is global — reset in an after hook
after { Spree::Checkout::Registry.reset! }
工厂::cart、:cart_with_line_items、:cart_ready_for_delivery、:cart_ready_to_complete。参见 spree-testing。
常见错误
- 查找
order.state、next!、checkout_flow——这些都已不存在。步骤是派生出来的;要求决定是否放行。 - 为结账字段在
Spree::Cart上添加模型验证——这会阻止所有部分写入。请使用 Registry 要求。 - 在
before_finalize中拒绝——款项已经转移。 - 在结账期间运行的代码中读取
line_item.order——请使用owner。 - 在店面中硬编码步骤列表——根据
completed_steps/current_step渲染,并通过requirements[].code进行路由。 - 写入已完成的购物车(会引发
ActiveRecord::ReadOnlyRecord)——请改用管理服务编辑订单。
延伸阅读
node_modules/@spree/docs/dist/developer/core-concepts/carts.md和orders.mdnode_modules/@spree/docs/dist/developer/customization/checkout.md— Registrynode_modules/@spree/docs/dist/developer/customization/workflows.md— 钩子node_modules/@spree/docs/dist/developer/sdk/store/cart-checkout.md- 相关技能:
spree-order-totals、spree-taxes、spree-payments、spree-promotions、spree-fulfillment、spree-workflows、spree-b2b - 源码:
spree/core/app/workflows/spree/carts/complete.rb、spree/core/lib/spree/checkout/