关于 spree-payments
Spree Payments 是 Spree Commerce 的支付域实现,面向构建和运营电商支付流程的开发者与商家。它涵盖支付方式、支付会话、捕获、退款及储值,并支持通过工作流扩展处理逻辑。
主要功能
- 01支持直接支付与会话式支付
- 02配置结账、发货时或手动捕获
- 03处理支付网关 Webhook 与幂等结算
- 04管理退款、Store Credit 和礼品卡
- 05通过工作流钩子扩展支付行为
适用场景
- 01配置店铺支付方式与捕获时机
- 02在购物车结账流程中处理网关支付
- 03依据网关事件完成支付
- 04管理订单退款和储值支付
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-payments安装后在 Claude Code 中输入/spree-payments确认已加载。
详细介绍
Spree 支付
PaymentMethod (pm_, belongs_to :store) — a configured way to pay (STI: SpreeStripe::Gateway, Spree::PaymentMethod::Check, …)
├── PaymentSession (ps_) — gateway-side session (e.g. Stripe PaymentIntent); owned by a cart or order
├── PaymentSetupSession (pss_) — save a payment method for later (e.g. SetupIntent); belongs to a customer
└── GatewayCustomer — provider customer id per customer (internal, encrypted at rest)
Payment (py_) — one attempt against a cart/order (cart_id + order_id; #owner)
├── source: CreditCard (card_) | PaymentSource (wallets, BNPL, bank) | StoreCredit (credit_)
└── Refund (re_) × n — with a RefundReason (rr_)
支付通过 payment.response_code == payment_session.external_id 与其会话关联。购物车可以包含多笔支付(例如,商店余额 + 卡)。
支付状态(无状态机)
status 是一个普通字符串(Spree::HasStatus),仅由支付工作流和 Spree::Payment::Processing 写入:
| 状态 | 含义 |
|---|---|
checkout | 已创建,尚未处理(默认值) |
processing | 已认领,准备调用网关(原子认领;阻止重复提交) |
pending | 已授权,尚未扣款 |
completed | 已扣款 — 只有这些支付才计入已付款金额 |
failed | 网关拒绝了该支付(可在同一行重试) |
void | 已撤销 |
invalid | 被更新的结账支付取代(has_invalid_status? — invalid? 是 ActiveModel 提供的方法) |
订单的 payment_status(none、authorized、partially_paid、paid、partially_refunded、refunded、overcharged、voided)由 Spree::Orders::UpdateStatuses 根据支付和退款计算(在支付/退款/履约事件发生时,以及每次已下单订单金额变更时重新计算,包括费用、手动折扣和商品)— 切勿手动赋值。“已退款”同时统计两个账本:Spree::Refund 行 以及 作为退款发放的商店余额(order.store_credit_refunds、StoreCredit#refunded_order_id),因此退款到商店余额的订单会显示为 partially_refunded / refunded,而不是 paid。Spree::Order.refunded / .partially_refunded 作用域会根据此状态筛选。
扣款时机
capture_method 是商店偏好设置(preferred_capture_method,默认值为 checkout),支付方式可以覆盖此设置(capture_method,nil = 继承):
| 值 | 行为 |
|---|---|
checkout | 下单时扣款 → completed |
on_dispatch | 结账时授权(pending),发货时扣款 |
manual | 结账时授权(pending);由员工自行决定何时扣款 |
await admin.store.update({ preferred_capture_method: 'on_dispatch' })
await admin.paymentMethods.update('pm_xxx', { capture_method: 'manual' }) // null → inherit store
await admin.orders.payments.capture('or_xxx', 'py_xxx')
await admin.orders.payments.void('or_xxx', 'py_xxx')
在 Ruby 中:payment_method.resolved_capture_method、Spree.payment_capture_workflow.call(payment:, amount: nil) — amount 的单位是分;较小的金额会扣除部分款项,并将剩余部分拆分为一笔新的待处理支付。Spree.payment_void_workflow.call(payment:)。
支付方式
| 属性 | 说明 |
|---|---|
type | API 发送简写形式(stripe、check、store_credit 等);数据库列中存储的是类名 |
store | 单一所属方 — current_store.payment_methods |
active | 非活跃方式在所有地方都会隐藏 |
storefront_visible | false = 仅员工可见(例如,为后台创建的订单提供的“发票”)。取代 display_on。Store API 的支付和支付会话端点只接受 active.storefront_visible 方式 — 仅员工可见方式的 ID 在此处会返回 404,不能借此绕过限制 |
capture_method | 覆盖设置,见上文 |
preferences | 网关凭据 — 以 YAML 格式存储在纯文本列中,未加密;请将数据库转储视为机密 |
子类中可覆盖的钩子:session_required?、setup_session_supported?、source_required?、available_for_order?(order)、available_for_store?(store)、payment_icon_name。Store API 会在购物车响应中嵌入 payment_methods(包含 session_required)。
安装网关
spree bundle add spree_stripe && spree generate spree_stripe:install
spree bundle add spree_adyen --github spree/spree_adyen && spree generate spree_adyen:install
spree bundle add spree_paypal_checkout && spree generate spree_paypal_checkout:install
spree bundle add spree_razorpay_checkout && spree generate spree_razorpay_checkout:install
spree restart
传统 Rails 应用:bundle add … && bin/rails g …:install。然后在控制面板(Settings → Payments)中创建支付方式,并向服务提供商注册其 webhook URL(payment_method.webhook_url → /api/v3/webhooks/payments/pm_…)。spree_stripe 是参考实现(会话、设置会话、webhook、钱包、Stripe Connect 付款)。
两种流程
基于会话(Stripe、Adyen、PayPal……)— session_required? == true
const opts = { spreeToken: cart.token }
// 1. After delivery is chosen (amount must include shipping); recreate/update if the total changes
const session = await client.carts.paymentSessions.create(cart.id, { payment_method_id: 'pm_xxx' }, opts)
// 2. Customer pays in the provider SDK with session.external_data.client_secret (3DS/redirects handled there)
// 3. Confirm — creates/settles the Payment, does NOT place the order
await client.carts.paymentSessions.complete(cart.id, session.id, { session_result: '…' }, opts)
// 4. Place the order (Order | OrderGroup on a multi-seller marketplace cart — narrow with isOrderGroup)
const result = await client.carts.complete(cart.id, opts)
会话状态:pending、processing、completed、failed、canceled、expired(没有取消端点——取消或过期由服务提供方处理,或根据 expires_at 判定)。
优先通过 Webhook 完成。 服务提供方的 Webhook(POST /api/v3/webhooks/payments/:payment_method_id)是权威信号:Spree 仅凭带前缀的 ID 查找支付方式(服务提供方不会发送 API 密钥或商店标头),将 Spree::Current.store 设为该支付方式所属的商店,根据该支付方式自身的密钥同步验证签名(失败时返回 401),将 Spree::Payments::HandleWebhookJob 加入队列,并返回 200;随后结算支付并通过 Carts::Complete 完成购物车。该任务也会在支付方式所属的商店中运行,因此,非默认商店的 Webhook 无需任何主机或标头路由即可正常工作。浏览器或 Webhook 无论哪个先到都会获胜;另一个会变成空操作。重放通过两层机制处理:时效性由 parse_webhook_event 中服务提供方的签名方案负责(Stripe 的 Stripe::Webhook.construct_event 会拒绝时间戳早于 5 分钟的事件——自定义网关必须实施服务提供方对应的机制);Spree::Payments::HandleWebhook 则确保支付操作幂等(在订单锁定期间,对已完成支付的再次投递会变成空操作),因此重放的事件无法重复支付或完成订单。绝不要将服务提供方重定向回来的结果视为已支付的证明。
直接支付(支票、货到付款、银行转账、采购订单)— session_required? == false
await client.carts.payments.create(cart.id, {
payment_method_id: 'pm_check',
metadata: { purchase_order_number: 'PO-12345' }, // optional; amount defaults to total minus store credit
}, opts)
await client.carts.complete(cart.id, opts)
422 错误代码:payment_session_required(该方式需要会话)、payment_method_unavailable(该方式的 available_for_order?(cart) 返回 false——创建支付会话时也会检查)。完成后,支付状态为 pending(若捕获模式为 checkout,则为 completed);资金到账后由工作人员捕获支付。
已保存的支付方式
client.customer.paymentSetupSessions.create({ payment_method_id }, { token: jwt }) → 使用服务提供方 SDK 进行确认,并传入 external_client_secret → .complete(id, { external_data: {} }) 会创建 CreditCard/PaymentSource。需要已登录的客户。
构建自定义支付方式
# app/models/my_gateway.rb
class MyGateway < Spree::PaymentMethod
preference :api_key, :string
preference :webhook_secret, :string
def session_required? = true
def source_required? = false
def payment_session_class = Spree::PaymentSessions::MyGateway # STI < Spree::PaymentSession
# `order:` receives the Cart during checkout
def create_payment_session(order:, amount: nil, external_data: {})
total = amount.presence || order.total_minus_store_credits
remote = client.create_session(amount_cents: Spree::Money.new(total, currency: order.currency).cents,
currency: order.currency, reference: order.prefixed_id)
payment_session_class.create!(owner: order, payment_method: self, amount: total, currency: order.currency,
status: 'pending', external_id: remote.id, customer: order.customer,
external_data: { client_secret: remote.client_secret })
end
def update_payment_session(payment_session:, amount: nil, external_data: {})
client.update_session(payment_session.external_id, amount: amount) if amount.present?
payment_session.update!(amount: amount) if amount.present?
payment_session
end
# Verify with the provider, settle the Payment, move the session. Must NOT complete the order.
def complete_payment_session(payment_session:, params: {})
remote = client.retrieve_session(payment_session.external_id)
case remote.status
when 'succeeded', 'authorized'
payment_session.process if payment_session.can_process?
payment_session.settle_payment!(captured: remote.status == 'succeeded') # false → payment stays pending
payment_session.complete unless payment_session.completed?
else
payment_session.fail if payment_session.can_fail?
end
payment_session
end
# Return { action: :captured|:authorized|:failed|:canceled, payment_session:, metadata: {} } or nil to ignore
def parse_webhook_event(raw_body, headers)
raise Spree::PaymentMethod::WebhookSignatureError unless client.valid_signature?(raw_body, headers['HTTP_X_SIGNATURE'], preferred_webhook_secret)
event = JSON.parse(raw_body)
session = payment_sessions.find_by(external_id: event.dig('data', 'session_id'))
return unless session
{ 'payment.succeeded' => { action: :captured, payment_session: session },
'payment.failed' => { action: :failed, payment_session: session } }[event['type']]
end
def payment_icon_name = 'my-gateway'
private
def client = MyProvider::Client.new(preferred_api_key)
end
# config/initializers/spree.rb — core reassigns this registry in its own after_initialize,
# so register inside after_initialize or the entry is wiped
Rails.application.config.after_initialize do
Spree.payment_methods << MyGateway
end
settle_payment!(captured:)是确认调用与 Webhook 共用的唯一结算路径——它会锁定所属对象,查找或创建支付记录,并进行确认;并发执行也安全。- 会话状态转换(
process、complete、fail、cancel、expire)采用不会引发异常的比较并交换写入;若竞争失败则返回false。可用的守卫方法包括can_process?、can_complete?、can_fail?…… - 对于设置会话:
setup_session_supported?、payment_setup_session_class、create_payment_setup_session(customer:, external_data:)、complete_payment_setup_session(setup_session:, params:)。 - 旧版银行卡网关继承自
Spree::Gateway(在服务提供方上采用 ActiveMerchant 风格的authorize/purchase/capture/void/credit)。
无需子类化即可通过工作流钩子扩展行为:payments.process.validate、payments.capture.before_capture、payments.void.after_void、payment_sessions.complete.after_complete、payments.handle_webhook.after_handle(Spree.hooks.register('payments.capture.validate', ->(wf) { wf.reject!('…') if … }))。请参阅 spree-workflows。
退款
退款仅限 Admin 操作(没有 Store API 路由),通过 Spree::Refunds::Create(Spree.refund_create_workflow)执行:先提交退款记录,再向支付网关退款;如果支付网关明确失败,则会删除尚未退款的记录。
await admin.orders.refunds.create('or_xxx', { payment_id: 'py_xxx', amount: '25.00', refund_reason_id: 'rr_xxx' })
参数是 refund_reason_id(较早版本的 @spree/admin-sdk 也接受 reason_id,但 API 会静默丢弃该参数,导致退款回退为使用商店的第一个退款原因)。
Spree.refund_create_workflow.call(payment: payment, amount: 25, reason: reason, refunder: admin_user)
# amount nil → full creditable balance (payment.credit_allowed)
钩子:refunds.create.validate、.before_refund、.after_refund。会发布 payment.refunded 事件;订单状态会变为 partially_refunded / refunded。退货流程触发的退款(退回原支付方式或转为商店积分)通过 Spree::Returns::Refund 执行,详见 spree-returns。
商店积分和礼品卡
商店积分(credit_) | 礼品卡(gc_) | |
|---|---|---|
| 归属 | 一位客户(及商店) | 一家商店;客户可选 |
| 发放方 | Admin(admin.customers.storeCredits.create('cust_xxx', { amount, currency, memo }))或退货/换货工作流(originator) | Admin(admin.giftCards.create,批量发放则使用 admin.giftCardBatches.create) |
| 结账时 | client.carts.storeCredits.apply(cartId, amount?) / .remove — 已登录客户 | client.carts.giftCards.apply(cartId, code) / .remove — 访客也可使用;与 discount_codes 分开 |
| 过期时间 | 永不过期;按最早获得的顺序使用(Spree::StoreCredit.oldest_first) | 每张卡单独设置 expires_at |
| 状态 | — | active、partially_redeemed、redeemed、canceled(另有按日期过期的状态) |
两者都通过 Spree::PaymentMethod::StoreCredit 付款,并减少应付金额,而非订单总额。两者都必须在客户选择支付方式之前应用,且不能作为支付方式选择:商店积分支付方式会从 cart.payment_methods 中排除;若使用该方式调用 POST /carts/:id/payments,则会返回 payment_method_unavailable。剩余的 amount_due 应使用实际支付方式收取。购物车只能使用礼品卡或商店积分,不能同时使用(应用任一项时,若购物车已应用另一项,操作都会被拒绝)。礼品卡支付金额为 min(spendable balance, total),并会在每次重新计算总额时调整,以反映配送、折扣或税费的变化;若无需再付款,则会释放礼品卡。更改商品仍会取消两者的应用状态(详见 spree-checkout)。工作流:Spree.gift_card_apply_workflow / gift_card_remove_workflow / gift_card_redeem_workflow / gift_card_cancel_workflow;Spree.store_credit_apply_service / store_credit_remove_service。礼品卡一旦已用于消费,就不能取消。礼品卡的 code 是持有者凭证:只有持有 read_gift_cards 权限的调用方才能在 Admin API 响应中查看完整代码(其他位置会遮盖,仅显示末四位,例如订单中);Webhook 投递日志会将其存储为 [REDACTED],公司成员也只能在同事的订单中看到遮盖后的代码。通过 Admin API 为礼品卡设置 customer_id 也需要 read_customers 权限(否则返回 403 required_permission)。Spree::StoreCreditCategory / StoreCreditType 是已弃用的空壳。
事件
payment.completed、payment.paid、payment.captured、payment.voided、payment.refunded、order.paid(订单已全额结清——拆分的 Marketplace 结账中也会针对每个子订单触发)、payment_session.{processing,completed,failed,canceled,expired}、payment_setup_session.*、gift_card.*。没有支付网关日志表——如需调查交易,请使用 payment.gateway_dashboard_payment_url 或 PaymentSession 的 external_data。详见 spree-events-webhooks。
故障排除
- 付款卡在
processing状态:认领操作已完成,但网关调用或崩溃处理尚未完成。failed和processing状态的付款都可以重新认领,因此重新运行工作流(Spree.payment_process_workflow.call(payment:))会在同一行记录上重试——请先检查服务提供商的控制面板。 - 已扣款但没有订单:Webhook 应该会完成订单——请确认服务提供商处的 Webhook URL 和密钥正确,并且任务正在运行(Solid Queue /
/jobs)。检查payment_session.status和cart.completed_at。 - Webhook 返回 401:
parse_webhook_event引发了WebhookSignatureError——密钥错误,或者你解析的是重新序列化后的正文,而不是raw_body。 - 直接付款被
payment_session_required拒绝:该付款方式需要使用会话流程。 - 会话金额不匹配:会话是在配送或优惠券更改总额之前创建的——请更新会话金额或创建新会话。
延伸阅读
node_modules/@spree/docs/dist/developer/core-concepts/payments.mdnode_modules/@spree/docs/dist/developer/how-to/custom-payment-method.mdnode_modules/@spree/docs/dist/developer/core-concepts/store-credits-gift-cards.mdnode_modules/@spree/docs/dist/integrations/payments/stripe.md(另有adyen.md、paypal.md、razorpay.md)- 源码:
spree_core中的Spree::PaymentMethod、Spree::Payment、Spree::PaymentSession、Spree::Payments::*、Spree::Refunds::Create;完整网关实现见spree/providers/stripe - 相关技能:
spree-checkout、spree-workflows、spree-returns、spree-events-webhooks、spree-security