Free

Để Claude tích hợp hai cổng thanh toán: Stripe + x402

Hai giao thức hoàn toàn khác nhau trong một app — Checkout hosted của Stripe + webhook, và HTTP 402 + ví trình duyệt của x402. Ba bẫy lỗi im lặng, một kiến trúc chạy cả hai luồng.


Gần đây tôi cắm cả Stripe (thẻ/fiat) lẫn x402 (USDC on-chain trên EVM) vào tier Pro của how2claude. Để Claude viết tích hợp cho hai giao thức hoàn toàn khác nhau — một bên Checkout hosted + webhook, bên kia HTTP 402 + ví trình duyệt — mất nguyên một buổi tối session. Va vào ba lỗi im lặng, và kết thúc với một kiến trúc chạy cả hai luồng cùng lúc.

Đây không phải hướng dẫn "tích hợp Stripe ra sao" — loại đó chỗ nào cũng có. Phần đáng đọc: hai giao thức xếp cạnh nhau ra sao, Claude dễ vấp ở đâu, và những khoảnh khắc nào bạn buộc phải ngồi mà nhìn nó.


Hai mô hình thanh toán

Chiều Stripe x402
Trigger button_to → chuyển hướng đến checkout.stripe.com POST /x402/subscribe → trả HTTP 402
Hành động người dùng Nhập thẻ trên trang Stripe hosted Ký trong ví trình duyệt
Trả kết quả webhook (checkout.session.completed) Request retry kèm header X-PAYMENT, gem settle đồng bộ
Dữ liệu cần lưu payment_intent_id + amount_total tx_hash + payer + amount
Độ phức tạp giao thức SDK lo hết Cần handshake viem + x402-fetch

Khác về căn bản: Stripe đẩy người dùng sang trang của họ, bạn chỉ verify webhook khi quay lại; x402 ở lại trên domain của bạn từ đầu đến cuối, làm handshake giao thức ở tầng HTTP.

Sự khác biệt đó dẫn dắt mọi quyết định kiến trúc bên dưới.

Làm controller gầy đi — đẩy method record xuống model

Ban đầu controller nhồi đầy field mapping:

# ❌ Phiên bản đầu
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... cả tá dòng field mapping
  )
end

Cả hai luồng đều persist Purchase + Subscription, nhưng field hoàn toàn khác. Mapping ở controller nghĩa là mỗi luồng chép lại logic mapping.

Migration (9f3e239) đẩy nó xuống model:

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

Tổng cộng bốn method: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Controller co lại còn một dòng:

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

Claude cực hợp với loại việc này: nó sẽ ngoan ngoãn map từng field, thêm test, thêm validates :provider, inclusion: { in: %w[stripe x402] }. Con người thường "chạy được trước đã" rồi mapping field mãi nằm rải rác trong các controller, không thoát ra được.

Nhịp: viết tay trước, rồi mới di sang gem

b2f0333 lần đầu tôi để Claude viết tích hợp x402 bằng tay — ba class:

  • X402::PaymentHandler — dựng 402 requirements, decode header PAYMENT-SIGNATURE
  • X402::FacilitatorClient — bọc /verify + /settle của x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — phát hiện header 402, trả PAYMENT-REQUIRED

449 dòng, chạy được, test pass.

Sáu giờ sau (9f3e239) tôi cho thay tất cả bằng gem x402-rails (giao thức v1, mode non-optimistic). Xóa ba class kia; controller giờ dùng DSL x402_paywall(amount:) và đọc từ request.env["x402.payment"]request.env["x402.settlement_result"].

Nhịp quan trọng: viết tay trước giúp bạn hiểu giao thức, rồi gem giải phóng bạn. Nếu khởi đầu bằng gem, Claude viết theo docs gem nhưng bạn không biết bên trong header 402 thực sự có gì hay /settle làm gì. Khi có thứ vỡ (luôn luôn có thứ vỡ), bạn không có nền để debug.

Mẫu này dùng được cho mọi giao thức/dịch vụ mới: cho Claude viết tay một lần, test xanh xong rồi cho thay bằng gem. Diff giữa hai bên là tài liệu học của bạn.

Đổi chain bằng Rails.env runtime, không đổi tay khi deploy

Initializer x402 (config/initializers/x402.rb) hardcode quy tắc:

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  # đợi facilitator settle xong mới tiếp, để lấy tx_hash đồng bộ
end

Cùng code: dev chạy base-sepolia (token test miễn phí), prod chạy base mainnet. Không phải đổi gì khi deploy. (Nguyên tắc này đến từ bài trước Để Claude deploy lên production — bất cứ thứ gì khác giữa dev và prod, lật bằng Rails.env.)

Dòng optimistic = false quan trọng: mode optimistic mặc định của gem cho request đi qua rồi đối soát sau; ta tắt vì muốn settlement_result.transaction (tx_hash) trước khi action return, để ghi đồng bộ vào hàng Purchase. Một hàng Purchase không có tx_hash thì vô giá trị với người dùng — họ muốn click qua BaseScan xem giao dịch.

Frontend: một bên hosted, bên kia tự xây

"Frontend" phía Stripe là một dòng:

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

User click, trình duyệt nhảy đến checkout.stripe.com. Phía bạn 0 code frontend.

Phía x402 (93746d8) cần một controller Stimulus:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Lazy-load — đừng phình vendor bundle
  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)
  })
  // ...
}

Hai điểm đáng chú ý:

  1. Lazy-load viem + x402-fetch (chỉ kéo từ jsdelivr lúc click nút lần đầu). Hai gói này gộp lại to; bundle vào vendor sẽ ép mọi user không trả tiền cũng phải tải. Lazy-load biến nó thành "tải khi muốn trả tiền".
  2. Dùng kết quả eth_requestAccounts, đừng dùng selectedAddress. selectedAddress đã deprecated, hầu hết ví trả giá trị cũ. Bản đầu Claude dùng selectedAddress (theo docs MDN); tôi đổi.

Còn một thứ: liệt kê mã lỗi. Ví từ chối ký là 4001, sai chain cần switch là CHAIN_SWITCH, cần trả tiền là PAYMENT_REQUIRED. Đừng string-match error.message — ví khác nhau viết khác nhau và bạn không viết test được.

Cái bẫy #1: button_to + Turbo nuốt im lặng 302 của Stripe

Commit 527f700 là cái tôi ngồi nhìn trình duyệt nửa giờ mới tìm ra.

Triệu chứng: click nút Subscribe ở /pricing, không có gì xảy ra. Không lỗi console, không lỗi network. Log Rails hiện 200 trả 302 → checkout.stripe.com/c/pay/cs_xxx. Trình duyệt không nhúc nhích.

Nguyên nhân: button_to sinh ra <form method="post">, và Turbo intercept submit form đó, xử lý response như TURBO_STREAM. TURBO_STREAM không follow 302 cross-origin. Response bị Turbo nuốt im lặng; trang đứng yên.

Fix:

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

Ba nút bị ảnh hưởng: Subscribe ở /pricing, Manage ở card "current plan" /pricing (nhảy đến billing.stripe.com), và Manage Subscription ở /accounts. Mỗi cái thêm data-turbo=false và một test regression.

Khi tôi để Claude debug, nó khám phá ba hướng sai: cấu hình Stripe (không), whitelist redirect_uri (không), CORS (sai hướng). Xung đột Turbo/Stripe không có trong docs Stripe lẫn docs Turbo — và gần như không có gì về nó trong dữ liệu training của Claude. Loại bẫy này chỉ bắt được bằng cách thấy 302 quay về ở tab network rồi tự hỏi "vậy sao trình duyệt không follow?".

Cái bẫy #2: Failed to resolve module specifier 'x402-fetch'

Sau khi cài gem x402-rails, console trình duyệt:

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

Nhưng tôi rõ ràng lazy-load qua await import("https://esm.run/[email protected]") — URL đầy đủ — vậy sao lại "resolve module specifier"?

Nguyên nhân gốc: gem x402-rails tự mang theo controller Stimulus phụ thuộc @hotwired/stimulus. Tôi pin gói đó trong config/importmap.rb, nhưng file vendor tương ứng vendor/javascript/@hotwired--stimulus.js chưa bao giờ được tải. importmap thấy file thiếu và âm thầm bỏ pin đó khỏi importmap được sinh ra. Cái fail không phải x402-fetch của tôi; là controller Stimulus của gem. Lỗi bubble up đến import gần nhất.

Chẩn đoán: bin/importmap json xuất ra importmap thực sự được sinh ra. So với config/importmap.rb — pin nào không có trong json nghĩa là file vendor của nó chưa tải.

Fix: bin/importmap pin @hotwired/stimulus để thực sự kéo file về.

Claude không phản xạ chạy bin/importmap json như sanity check sau khi cài gem. Đó là việc của bạn. Nếu dùng importmap, sau khi cài bất cứ gem nào mang theo controller Stimulus, chạy bin/importmap json một lần và xác nhận không có pin nào bị bỏ âm thầm.

Cái bẫy #3: YAML diễn giải địa chỉ ví 0x... thành integer

Trong credentials:

x402:
  wallet_address: 0x1234abcd...

Khi Rails load, YAML parse 0x1234abcd... thành integer (literal hex). Lúc X402.configure nhận giá trị, kiểu đã hỏng, và gem sinh ra paywall requirements kỳ lạ.

Fix một ký tự: thêm dấu nháy.

x402:
  wallet_address: "0x1234abcd..."

Claude không thêm dấu nháy khi viết template credentials — dữ liệu training của nó đầy ví dụ YAML chuỗi trần. Chỉ kích hoạt khi prefix tình cờ là 0x / true / false / chữ số. Loại bẫy "parsing đặc biệt YAML" này chỉ kích hoạt khi bạn điền giá trị thật.

Vì sao một app cần hai luồng thanh toán

Stripe phủ 99% người dùng — thẻ tín dụng / Apple Pay / Google Pay. Cho luồng $9.99/tháng, trải nghiệm không gì sánh được.

x402 phủ 1% còn lại nhưng quan trọng: người dùng crypto-native, người dùng quốc tế muốn stablecoin, và lập trình viên viết agent tự động (agent của họ cần tự trả tiền truy cập API trả phí — 402 được thiết kế cho điều đó).

Quyết định sản phẩm chốt: tier hàng tháng không có x402. $9.99/tháng mà ký ví mỗi tháng UX kinh khủng. Chỉ bật x402 ở $99/năm, nơi ma sát phân bổ thành mỗi năm một lần.

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

Một dòng if trong _plan_card.html.erb quyết định card nào hiện nút USDC. Đơn giản như thế.


Để Claude tích hợp thanh toán — checklist đầy đủ:

  1. Hiểu hai giao thức tách bạch trước khi để Claude viết code. Stripe đi hosted Checkout + webhook; x402 đi HTTP 402 + ví trình duyệt — đừng kỳ vọng Claude tự phân biệt nổi.
  2. Method record thuộc về model. Controller gọi một dòng; tất cả field mapping trong model. Thêm inclusion: { in: %w[stripe x402] } làm cổng kiểu.
  3. Với giao thức mới, viết tay trước, rồi đổi sang gem. Diff giữa hai bên là tài liệu học của bạn.
  4. Đổi chain/mode runtime bằng Rails.env. Stripe test/live, x402 base-sepolia/base — tất cả lật qua Rails.env.production?.
  5. Mọi button_to Stripe cần data-turbo=false. Không thì Turbo nuốt im lặng 302 cross-origin.
  6. Sau khi cài bất cứ gem nào có controller Stimulus, chạy bin/importmap json. importmap âm thầm bỏ pin có file vendor thiếu.
  7. Đặt nháy cho mọi credentials trông như prefix số. 0x... / true / 07 bị YAML parse đặc biệt.

Phần khó của để Claude viết thanh toán không phải bản thân giao thức — mà là biên giới tích hợp (Turbo vs Stripe, importmap vs gem, YAML vs địa chỉ ví). Đó là khoảnh khắc bạn buộc phải ngồi đó nhìn.