免費

讓 Claude 接兩種支付:Stripe + x402

讓 Claude 寫兩種完全不同協定的支付整合(Stripe 託管 + x402 鏈上錢包),踩了三個靜默失敗的坑,跑通一套同時跑兩條支付線的架構。


最近給 how2claude 的 Pro 套餐同時接了 Stripe(信用卡/法幣)和 x402(EVM 鏈上 USDC)。讓 Claude 寫兩種完全不同協定的支付整合——一個託管 Checkout + webhook,另一個 HTTP 402 + 瀏覽器錢包——花了一整個晚上的 session。踩了三個靜默失敗的坑,也跑通了一套可以同時跑兩條支付線的架構。

這篇是實戰記錄,不是「如何接 Stripe」教學——那種文件滿地都是。關注點在:兩套協定該如何各安其位、Claude 寫哪些東西最容易翻車、哪些地方必須你親自盯著。


兩種支付範式

維度 Stripe x402
觸發方式 button_to → 重新導向到 checkout.stripe.com POST /x402/subscribe → 回傳 HTTP 402
使用者動作 在 Stripe 託管頁輸入卡號 瀏覽器錢包簽名
結果回傳 webhook (checkout.session.completed) 請求重試帶 X-PAYMENT header,gem 同步結算
我要持久化的資料 payment_intent_id + amount_total tx_hash + payer + amount
協定複雜度 SDK 一把梭 需要 viem + x402-fetch 協定握手

本質不同:Stripe 把使用者推走去它的頁面,你只需要在使用者回來時核對 webhook;x402 從頭到尾在你自己的網域下跑,HTTP 協定擴充層面直接完成協定握手。

這個差異決定了後面所有架構決策。

控制器薄下來:record 方法抽到模型層

最早控制器裡塞滿了欄位映射:

# ❌ 早期寫法
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... 十多行 field mapping
  )
end

兩種支付都要持久化 Purchase + Subscription,但欄位完全不同。欄位映射塞在控制器裡,每種支付都要抄一遍映射邏輯。

遷移(9f3e239)把這部分抽到了模型:

class Purchase < ApplicationRecord
  validates :provider, presence: true, inclusion: { in: %w[stripe x402] }

  def self.record_x402!(article:, user:, payment:, settlement:)
    create!(
      article: article,
      user: user,
      provider: "x402",
      wallet_address: payment[:payer],
      amount_cents: article.price_cents,
      tx_hash: settlement.transaction,
      purchased_at: Time.current
    )
  end

  def self.record_stripe!(session:, user:)
    create!(
      article_id: session.metadata.article_id,
      user: user,
      provider: "stripe",
      amount_cents: session.amount_total,
      stripe_payment_intent_id: session.payment_intent,
      purchased_at: Time.current
    )
  end
end

四個方法:Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!。控制器變成一行:

Purchase.record_x402!(article:, user:, payment:, settlement:)

讓 Claude 幹這件事特別合適:它會老老實實逐個欄位映射、加測試、加 validates :provider, inclusion: { in: %w[stripe x402] }。人寫這種程式容易「先跑通再說」,欄位就散落控制器裡出不來了。

節奏:手搖先過,再遷到 gem

b2f0333 我第一次讓 Claude 寫 x402 整合,它手搖了三個類別:

  • X402::PaymentHandler — build 402 requirements、解 PAYMENT-SIGNATURE header
  • X402::FacilitatorClient — 包 x402.org/facilitator/verify + /settle
  • app/controllers/concerns/content_gate.rb — 偵測 402 header 回傳 PAYMENT-REQUIRED

449 行程式,能跑,測試也過。

6 小時後(9f3e239)我讓它全換成 x402-rails gem(v1 協定 + 非 optimistic 模式),把那三個類別刪掉,控制器改用 x402_paywall(amount:) DSL,從 request.env["x402.payment"]request.env["x402.settlement_result"] 讀資料。

節奏有講究:手搖先讓你理解協定,gem 後讓你解放出來。如果一上來就裝 gem,Claude 會按 gem 文件寫程式,但你不知道 402 header 裡到底裝了什麼、facilitator 的 /settle 在幹啥。等到出問題(總會出問題),你沒底氣除錯。

這個模式適用所有新協定/新服務:讓 Claude 手搖一遍,過完測試後再讓它換 gem。前後對比 diff 就是你的學習材料。

執行時按 Rails.env 切鏈,不要部署時手切

x402 initializer(config/initializers/x402.rb)直接寫死規則:

X402.configure do |config|
  config.wallet_address = Rails.application.credentials.dig(:x402, :wallet_address)
  config.facilitator = Rails.application.credentials.dig(:x402, :facilitator_url) ||
                       "https://facilitator.payai.network"
  # Production → Base mainnet (real USDC). Dev/test → Base Sepolia (free testnet USDC).
  config.chain = Rails.env.production? ? "base" : "base-sepolia"
  config.currency = "USDC"
  config.version = 1
  config.optimistic = false  # 等 facilitator settle 回傳再繼續,才能同步拿到 tx_hash
end

同一份程式 dev 跑 base-sepolia(免費測試幣),prod 跑 base mainnet。部署時不需要改任何東西。(這個原則前一篇讓 Claude 做生產部署裡講過——凡是 dev 和 prod 不一樣的設定,全部用 Rails.env 翻。)

optimistic = false 這一行很關鍵:gem 預設 optimistic 模式是先放行、後對帳;我們關掉它是因為想在 action 回傳前拿到 settlement_result.transaction(tx_hash),同步寫進 Purchase 表。拿不到 tx_hash 的 Purchase 紀錄對使用者無價值——他要能點開 BaseScan 看交易。

前端:一邊託管,一邊自己造輪子

Stripe 側的「前端」是一行:

<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
      class: "...",
      form: { class: "w-full", data: { turbo: false } } do %>
  <%= t("pricing.subscribe") %>
<% end %>

使用者一點,瀏覽器跳到 checkout.stripe.com。你 0 前端程式。

x402 側(93746d8)我讓 Claude 寫了個 Stimulus controller:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // 懶載入,不塞 vendor
  const viem = await import("https://esm.run/viem@2")
  const { wrapFetchWithPayment } = await import("https://esm.run/[email protected]")

  const [account] = await window.ethereum.request({ method: "eth_requestAccounts" })
  const walletClient = viem.createWalletClient({ account, transport: viem.custom(window.ethereum) })
  const fetchWithPayment = wrapFetchWithPayment(fetch, walletClient)

  const res = await fetchWithPayment(this.endpointValue, {
    method: "POST",
    headers: { "Accept": "application/json" },
    body: new URLSearchParams(this.paramsValue)
  })
  // ...
}

值得注意的兩點:

  1. 懶載入 viem + x402-fetch(第一次點按鈕時才從 jsdelivr 拉套件)。這兩個套件加起來很大,塞進 vendor 會讓所有不付款的使用者都下載。懶載入把它變成「想付款再下」。
  2. eth_requestAccounts 的結果,不用 selectedAddressselectedAddress 已 deprecated,大部分錢包回傳的是過時值。Claude 寫第一版的時候用了 selectedAddress(按 MDN 文件),我改成前者。

然後還要做一件事:錯誤碼列舉化。錢包拒絕簽名是 4001,鏈不對要切是 CHAIN_SWITCH,不付款要 402 重新導向是 PAYMENT_REQUIRED。不要靠 error.message 做字串匹配——不同錢包訊息不一樣,測試寫不出來。

踩坑 #1:button_to + Turbo 靜默吞掉 Stripe 302

527f700 這個 commit 是我盯著瀏覽器半小時才發現的。

症狀:點 /pricing 的 Subscribe 按鈕,頁面啥都沒發生。沒有控制台錯誤,沒有網路錯誤,Rails 日誌顯示 200 回傳了 302 → checkout.stripe.com/c/pay/cs_xxx。但瀏覽器壓根沒跳轉。

原因:button_to 會生成一個 <form method="post">,Turbo 會攔截這個 form 的提交,把回應當成 TURBO_STREAM 處理。TURBO_STREAM 不跟 cross-origin 的 302。回應預設被 Turbo 吞了,頁面紋絲不動。

修復:

 <%= button_to stripe_checkouts_subscription_path(plan: plan.key),
       class: "...",
-      form: { class: "w-full" } do %>
+      form: { class: "w-full", data: { turbo: false } } do %>

涉及三個按鈕:/pricing 的 Subscribe、/pricing 上「目前套餐」卡片裡的 Manage(跳到 billing.stripe.com)、/accounts 的 Manage Subscription。給每個都加了 data-turbo=false,每個加了回歸測試。

讓 Claude 先除錯這個問題的時候它懷疑過三個方向:Stripe 設定錯(不是)、redirect_uri 白名單(不是)、CORS(錯的方向)。Turbo 和 Stripe 的衝突不在 Stripe 文件裡,也不在 Turbo 文件裡——Claude 的訓練資料裡也幾乎沒有。這種坑你只能靠 network tab 看到 302 回應回來了,然後問自己「那為什麼沒跳」。

踩坑 #2:Failed to resolve module specifier 'x402-fetch'

裝完 x402-rails gem 後前端控制台:

Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.

但我明明懶載入了 await import("https://esm.run/[email protected]") 啊——完整 URL,為什麼會 "resolve module specifier" 失敗?

根因:x402-rails gem 自己帶了一個 Stimulus controller 依賴 @hotwired/stimulus。我 config/importmap.rb 裡 pin 了這個套件,但對應的 vendor 檔案 vendor/javascript/@hotwired--stimulus.js 根本沒下載。importmap 發現檔案不存在,就靜默把這個 pin 從生成的 importmap 裡丟掉了。失敗的不是我的 x402-fetch,是 gem 的 Stimulus 控制器。錯誤冒泡到最近的 import。

診斷:bin/importmap json 輸出實際生成的 importmap,跟 config/importmap.rb 對比——如果有 pin 沒出現在 json 裡,對應 vendor 檔案沒下載。

修復:bin/importmap pin @hotwired/stimulus 重跑一遍,把檔案真正拉下來。

Claude 寫 gem 整合程式的時候不會主動 bin/importmap json 做健全性檢查。這種事只能人看。如果你用 importmap,裝任何帶 Stimulus 控制器的 gem 之後,先跑一次 bin/importmap json 確認沒有 pin 被悄悄丟掉。

踩坑 #3:YAML 把 0x... 錢包位址當整數

credentials 裡存錢包位址:

x402:
  wallet_address: 0x1234abcd...

Rails 載入時,YAML 把 0x1234abcd... 解析成了整數(hex literal)。等 X402.configure 拿到這個值時,型別已經錯了,gem 內部拼 paywall requirement 的時候生成奇怪的結構。

修復一個字元:加引號。

x402:
  wallet_address: "0x1234abcd..."

Claude 寫 credentials 範本時沒加引號——它的訓練資料裡 YAML 範例基本都是裸字串。只有前綴恰好是 0x / true / false / 純數字的時候才出問題。這類「YAML 特殊解析」的坑只有你填真值進去才會觸發。

為什麼一個應用要接兩種支付

Stripe 覆蓋 99% 的使用者——信用卡/Apple Pay/Google Pay,月付 $9.99 那種場景體驗最順。

x402 覆蓋剩下 1% 但重要的人:加密使用者、想用穩定幣的國際使用者、寫自動化 agent 的開發者(他們的 agent 需要能自己付錢存取付費 API——402 協定就是為此設計的)。

關鍵決策:月付套餐不接 x402。$9.99/月讓人每月開錢包簽一次字,體驗爛。我們只在年付 $99 上開 x402,摩擦成本攤薄到一次/年。

<% if plan.interval == "year" %>
  <%= render "shared/x402_pay_button", ... %>
<% end %>

_plan_card.html.erb 裡一行 if,決定了哪些卡片顯示 USDC 付款按鈕。就這麼簡單。


讓 Claude 接支付,完整清單:

  1. 兩種協定分開理解再讓它寫。Stripe 走 hosted Checkout + webhook,x402 走 HTTP 402 + 瀏覽器錢包——別指望 Claude 自己能分得清。
  2. record 方法放模型層。控制器只呼叫一行,所有 field mapping 在模型裡。加 inclusion: { in: %w[stripe x402] } 做型別閘。
  3. 新協定先手搖再遷 gem。前後 diff 就是你的學習材料。
  4. 執行時按 Rails.env 切鏈/切 mode。Stripe test/live、x402 base-sepolia/base,全部用 Rails.env.production? 翻。
  5. 所有 Stripe button_to 加 data-turbo=false。否則 302 到 cross-origin 被 Turbo 靜默吞掉。
  6. 裝任何帶 Stimulus 控制器的 gem,跑 bin/importmap json 驗證。importmap 會靜默丟 vendor 檔案缺失的 pin。
  7. 所有看起來像數字前綴的 credentials 加引號0x... / true / 07 這類 YAML 會特殊解析。

讓 Claude 寫支付的真正難點不在協定本身,在協定邊界處的整合(Turbo 和 Stripe 的衝突、importmap 和 gem 的衝突、YAML 和錢包位址的衝突)。這些是你必須親自坐在那盯的地方。