mcpField
S
spree-checkout

spree-checkout

github.com/spree开发10225 次安装4 周前更新
代码仓库

关于 spree-checkout

面向开发者的 Spree 6 结账技能,介绍购物车要求、扩展方式,以及将购物车转换为订单的工作流,并说明结账扩展的实现细节与保障机制。

主要功能

  1. 01基于数据的购物车要求
  2. 02提示性的结账步骤
  3. 03自定义要求与步骤
  4. 04完成钩子与故障恢复
  5. 05购物车到订单的生命周期

适用场景

  1. 01为客户提交的信息添加结账要求
  2. 02自定义店铺的结账步骤
  3. 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 是分组提示。

内置提示性要求(每次读取购物车都会报告):

codestep触发条件
line_items_requiredcart购物车为空
email_requiredaddress没有电子邮件
ship_address_requiredaddress有需要配送的商品,但没有地址
delivery_method_requireddelivery某个履约项尚未选择配送方式
payment_requiredpayment有效付款未覆盖 amount_due_at_checkout
po_number_requiredaddress买方公司要求填写 PO 编号(使用员工密钥的购物车除外)
order_minimum_not_metcart未达到 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始终存在
deliverycart.delivery_step_required?(有商品且并非全部为数字商品)
paymentcart.payment_required?(总额大于零)
confirmcart.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

三个阶段,具有明确的事务边界:

  1. 准备:先在锁外执行重放检查和合同价格确认。随后,在 cart.with_lock 中:并发完成保护 → 锁内重新计算(此时计算实际扣款总额,绝不信任先前请求中的值)→ 可选的 expected_total 偏差保护(cart_changed)→ Requirements#call(completion: true) → carts.complete.validate 钩子 → 设置 completing_at → 创建一个 draft 订单,复制商品行、配送方式 + 已选费率、TaxLine/Discount/Fee 行、促销、地址快照、税务标识和 PO 文档;重新指向付款、付款会话、预留和优惠券代码。
  2. 付款(external_step,不在任何事务中):如果付款尚未完成,则处理付款。
  3. 最终确认:执行 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。

KeyKindUse 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/令牌。

故障排查:“为什么无法完成此购物车?”

  1. 读取 cart.requirements(spree console:Spree::Checkout::Requirements.new(cart).call(completion: true))。仅在完成时检查的集合还会加入库存/已停售/数量规则/访客策略相关失败项。
  2. completion_in_progress / 409 → 另一次尝试正在持有 completing_at(TTL 为 5 分钟)。请重试;如果付款可能正在进行,切勿手动清除它。
  3. 客户已付款,却出现 payment_required → 礼品卡或商店余额已被移除:任何商品变更都会运行 Spree::Carts::Recalculate,取消应用礼品卡和结账时使用的商店余额。编辑商品后请重新应用。
  4. coupon_code_unavailable → 购物车不再持有其单次使用批次码(其他购物车已取得该码,或某个订单已使用该码)。下次读取购物车时会发出警告并移除该码;购物者需要重新确认总额(参见 spree-promotions)。
  5. 没有配送方式且出现 delivery_method_required → 地址没有匹配的配送区域;cart.warnings 会为每件商品附带 delivery_unavailable。
  6. 自定义要求始终无法满足 → 你的 satisfied: lambda 读取了店面从未写入的数据(例如购物车上的 custom_fields——请使用 metadata)。
  7. 被钩子拒绝 → 错误消息来自某个 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.md
  • node_modules/@spree/docs/dist/developer/customization/checkout.md — Registry
  • node_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/

评论

支持 Markdown