mcpField
S
spree-payments

spree-payments

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

关于 spree-payments

Spree Payments 是 Spree Commerce 的支付域实现,面向构建和运营电商支付流程的开发者与商家。它涵盖支付方式、支付会话、捕获、退款及储值,并支持通过工作流扩展处理逻辑。

主要功能

  1. 01支持直接支付与会话式支付
  2. 02配置结账、发货时或手动捕获
  3. 03处理支付网关 Webhook 与幂等结算
  4. 04管理退款、Store Credit 和礼品卡
  5. 05通过工作流钩子扩展支付行为

适用场景

  1. 01配置店铺支付方式与捕获时机
  2. 02在购物车结账流程中处理网关支付
  3. 03依据网关事件完成支付
  4. 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:)。

支付方式

属性说明
typeAPI 发送简写形式(stripe、check、store_credit 等);数据库列中存储的是类名
store单一所属方 — current_store.payment_methods
active非活跃方式在所有地方都会隐藏
storefront_visiblefalse = 仅员工可见(例如,为后台创建的订单提供的“发票”)。取代 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.md
  • node_modules/@spree/docs/dist/developer/how-to/custom-payment-method.md
  • node_modules/@spree/docs/dist/developer/core-concepts/store-credits-gift-cards.md
  • node_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

评论

支持 Markdown