Free

ให้ Claude เชื่อมต่อสองช่องทางจ่ายเงิน: Stripe + x402

สองโปรโตคอลที่ต่างกันสิ้นเชิงในแอปเดียว — Stripe Checkout ที่โฮสต์ + webhook และ x402 HTTP 402 + กระเป๋าเงินบราวเซอร์ สามกับดักล้มเหลวเงียบ สถาปัตยกรรมเดียวที่รันทั้งสองทาง


เมื่อเร็ว ๆ นี้ผมต่อทั้ง Stripe (บัตรเครดิต/fiat) และ x402 (USDC on-chain บน EVM) เข้ากับ tier Pro ของ how2claude ให้ Claude เขียน integration สำหรับสองโปรโตคอลที่ต่างกันสิ้นเชิง — ฝ่ายหนึ่ง Checkout โฮสต์ + webhook อีกฝ่าย HTTP 402 + กระเป๋าในบราวเซอร์ — ใช้เวลา session หนึ่งเย็นเต็ม ๆ ติดกับดักเงียบสามตัว และสุดท้ายได้สถาปัตยกรรมที่รันทั้งสองช่องทางพร้อมกัน

นี่ไม่ใช่คู่มือ "วิธีเชื่อม Stripe" — คู่มือแบบนี้มีอยู่ทุกที่ ส่วนที่น่าสนใจคือ: สองโปรโตคอลอยู่ข้างกันยังไง Claude ล้มหน้าคะมำที่ไหนง่ายที่สุด และช่วงไหนที่คุณต้องนั่งเฝ้าดูเอง


สองกระบวนทัศน์การจ่ายเงิน

มิติ Stripe x402
Trigger button_to → redirect ไป checkout.stripe.com POST /x402/subscribe → คืน HTTP 402
การกระทำของผู้ใช้ กรอกบัตรในหน้าที่ Stripe โฮสต์ เซ็นในกระเป๋าบราวเซอร์
ส่งผลลัพธ์ webhook (checkout.session.completed) Request ถูกส่งซ้ำพร้อม header X-PAYMENT gem settle แบบ synchronous
ข้อมูลที่ต้องเก็บ payment_intent_id + amount_total tx_hash + payer + amount
ความซับซ้อนโปรโตคอล SDK ทำทุกอย่าง ต้อง handshake viem + x402-fetch

ต่างกันในระดับพื้นฐาน: Stripe ผลักผู้ใช้ออกไปยังหน้าของตัวเอง คุณแค่ตรวจ webhook ตอนผู้ใช้กลับมา; x402 อยู่บน domain คุณตั้งแต่ต้นจบ ทำ handshake โปรโตคอลบน HTTP layer

ความแตกต่างนี้ขับเคลื่อนการตัดสินใจเชิงสถาปัตยกรรมทั้งหมดข้างล่าง

ทำให้ controller บาง — ดัน method record ลง model

ตอนแรก controller ถูกยัดด้วย field mapping:

# ❌ เวอร์ชันแรก
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 แต่ field ต่างกันโดยสิ้นเชิง Mapping ใน controller หมายความว่าแต่ละช่องทางคัดลอก logic mapping

Migration (9f3e239) ดันมันลง 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

รวมสี่ method: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe! Controller เหลือบรรทัดเดียว:

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

Claude เก่งงานแบบนี้มาก: มันจะ map ทุก field อย่างเชื่อฟัง เพิ่ม test และเพิ่ม validates :provider, inclusion: { in: %w[stripe x402] } คนมักมีแนวโน้ม "ให้มันทำงานก่อน" และ field mapping กระจัดกระจายไปทั่ว controller โดยไม่เคยหลุดออกมา

จังหวะ: เขียนมือก่อน ค่อย migrate ไป gem

ใน b2f0333 ผมให้ Claude เขียน integration x402 ครั้งแรกด้วยมือ — สามคลาส:

  • X402::PaymentHandler — สร้าง 402 requirements, decode header PAYMENT-SIGNATURE
  • X402::FacilitatorClient — ห่อ /verify + /settle ของ x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — ตรวจ header 402 คืน PAYMENT-REQUIRED

449 บรรทัด ทำงาน test ผ่าน

หกชั่วโมงต่อมา (9f3e239) ผมให้มันเปลี่ยนทั้งหมดเป็น gem x402-rails (โปรโตคอล v1, โหมด non-optimistic) ลบสามคลาสนั้น; controller ตอนนี้ใช้ DSL x402_paywall(amount:) และอ่านจาก request.env["x402.payment"] และ request.env["x402.settlement_result"]

จังหวะสำคัญ: เขียนมือก่อนทำให้คุณเข้าใจโปรโตคอล แล้ว gem ค่อยปลดปล่อยคุณ ถ้าเริ่มจาก gem Claude จะเขียนตาม docs ของ gem และคุณไม่รู้ว่าใน header 402 มีอะไรหรือ /settle ทำอะไรจริง ๆ พอมีอะไรพัง (มีอะไรพังเสมอ) คุณไม่มีฐานสำหรับ debug

รูปแบบนี้ใช้ได้กับโปรโตคอล/บริการใหม่ใด ๆ: ให้ Claude เขียนมือครั้งนึง test เขียว แล้วค่อยให้สลับไป gem diff ระหว่างสองอันคือสื่อเรียนรู้ของคุณ

สลับ chain ผ่าน Rails.env ที่ runtime อย่าสลับมือตอน deploy

initializer x402 (config/initializers/x402.rb) hardcode กฎไว้:

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  # รอ settle ของ facilitator ก่อนไปต่อ เพื่อจับ tx_hash แบบ synchronous
end

โค้ดเดียวกัน: dev รัน base-sepolia (token ทดสอบฟรี) prod รัน base mainnet ไม่ต้องเปลี่ยนอะไรตอน deploy (หลักการนี้มาจากบทความก่อนหน้า ให้ Claude deploy ขึ้น production — อะไรที่ต่างระหว่าง dev กับ prod สลับผ่าน Rails.env)

บรรทัด optimistic = false สำคัญ: โหมด optimistic default ของ gem ปล่อย request ผ่านและกระทบยอดทีหลัง; เราปิดเพราะอยากได้ settlement_result.transaction (tx_hash) ก่อน action return เพื่อเขียน synchronous ลงแถว Purchase แถว Purchase ที่ไม่มี tx_hash ไร้ค่าสำหรับผู้ใช้ — เขาอยากคลิกไปดูธุรกรรมบน BaseScan

Frontend: ฝั่งหนึ่งโฮสต์ อีกฝั่งสร้างเอง

"frontend" ฝั่ง 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 ฝั่งคุณโค้ด frontend ศูนย์

ฝั่ง x402 (93746d8) ต้องใช้ Stimulus controller:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Lazy-load — อย่าทำให้ 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)
  })
  // ...
}

สองสิ่งที่น่าสังเกต:

  1. Lazy-load viem + x402-fetch (ดึงจาก jsdelivr เฉพาะตอนคลิกปุ่มครั้งแรก) สองแพ็กเกจรวมกันใหญ่ bundle เข้า vendor จะบังคับให้ผู้ใช้ไม่จ่ายทุกคนต้องดาวน์โหลด Lazy-load เปลี่ยนเป็น "ดาวน์โหลดเฉพาะถ้าจะจ่าย"
  2. ใช้ผล eth_requestAccounts อย่าใช้ selectedAddress selectedAddress deprecated และกระเป๋าส่วนใหญ่คืนค่าล้าสมัย Claude เวอร์ชันแรกใช้ selectedAddress (ตาม MDN docs); ผมเปลี่ยน

อีกเรื่อง: enumerate รหัส error กระเป๋าปฏิเสธการเซ็นคือ 4001 chain ผิดต้อง switch คือ CHAIN_SWITCH ต้องจ่ายเงินคือ PAYMENT_REQUIRED อย่า string-match error.message — กระเป๋าใช้คำต่างกันและคุณเขียน test กับมันไม่ได้

กับดัก #1: button_to + Turbo กลืน 302 ของ Stripe เงียบ ๆ

Commit 527f700 เป็นตัวที่ผมนั่งจ้องบราวเซอร์ครึ่งชั่วโมงถึงหาเจอ

อาการ: คลิกปุ่ม Subscribe ใน /pricing ไม่มีอะไรเกิดขึ้น ไม่มี error console ไม่มี error network log Rails แสดง 200 คืน 302 → checkout.stripe.com/c/pay/cs_xxx บราวเซอร์ไม่ขยับ

สาเหตุ: button_to สร้าง <form method="post"> และ Turbo ดักการ submit form จัดการ response เป็น TURBO_STREAM TURBO_STREAM ไม่ตาม 302 cross-origin Response ถูก Turbo กลืนเงียบ ๆ หน้าหยุดนิ่ง

แก้:

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

ปุ่มได้รับผลกระทบสามปุ่ม: Subscribe ใน /pricing, ปุ่ม Manage บน card "แผนปัจจุบัน" ใน /pricing (กระโดดไป billing.stripe.com) และ Manage Subscription ใน /accounts แต่ละปุ่มได้ data-turbo=false และ regression test

พอให้ Claude debug เรื่องนี้ มันสำรวจสามทิศทางที่ผิด: configuration Stripe (ไม่ใช่) whitelist redirect_uri (ไม่ใช่) CORS (ทิศทางผิด) ความขัดแย้ง Turbo/Stripe ไม่อยู่ใน docs Stripe หรือ docs Turbo — และแทบไม่มีเรื่องนี้ใน training data ของ Claude จับกับดักแบบนี้ได้โดยเห็น 302 กลับในแท็บ network แล้วถามตัวเอง "แล้วทำไมบราวเซอร์ไม่ตาม?"

กับดัก #2: Failed to resolve module specifier 'x402-fetch'

หลังติดตั้ง gem x402-rails console บราวเซอร์:

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

แต่ผมก็ lazy-load ชัด ๆ ผ่าน await import("https://esm.run/[email protected]") — URL เต็ม — แล้วทำไม "resolve module specifier"?

สาเหตุราก: gem x402-rails เอง bring Stimulus controller ที่พึ่ง @hotwired/stimulus ผม pin แพ็กเกจนั้นใน config/importmap.rb แต่ไฟล์ vendor ที่สอดคล้อง vendor/javascript/@hotwired--stimulus.js ไม่เคยถูกดาวน์โหลด importmap สังเกตว่าไฟล์ขาดและ ทิ้ง pin นั้นจาก importmap ที่ generate เงียบ ๆ สิ่งที่ fail ไม่ใช่ x402-fetch ของผม แต่เป็น Stimulus controller ของ gem error bubble ขึ้นไปยัง import ที่ใกล้ที่สุด

วินิจฉัย: bin/importmap json พิมพ์ importmap ที่ generate จริง เทียบกับ config/importmap.rb — pin ใดที่ไม่อยู่ใน json แปลว่าไฟล์ vendor ของมันไม่ได้ดาวน์โหลด

แก้: bin/importmap pin @hotwired/stimulus เพื่อดึงไฟล์จริง

Claude ไม่ได้รัน bin/importmap json โดยอัตโนมัติเป็น sanity check หลังติดตั้ง gem นั่นเป็นหน้าที่คุณ ถ้าใช้ importmap หลังติดตั้ง gem ใด ๆ ที่ bring Stimulus controller รัน bin/importmap json ครั้งนึงและยืนยันว่าไม่มี pin ที่ถูกทิ้งเงียบ

กับดัก #3: YAML ตีความที่อยู่กระเป๋า 0x... เป็น integer

ใน credentials:

x402:
  wallet_address: 0x1234abcd...

เมื่อ Rails โหลด YAML parse 0x1234abcd... เป็น integer (hex literal) พอ X402.configure ได้ค่า type พังแล้ว และ gem สร้าง paywall requirements แปลก ๆ

แก้ตัวเดียว: ใส่เครื่องหมายอัญประกาศ

x402:
  wallet_address: "0x1234abcd..."

Claude ไม่ใส่เครื่องหมายอัญประกาศตอนเขียน template credentials — training data ของมันเต็มไปด้วยตัวอย่าง YAML string เปลือย ติดเฉพาะเมื่อ prefix บังเอิญเป็น 0x / true / false / ตัวเลข กับดัก "parsing พิเศษของ YAML" แบบนี้จะเกิดเฉพาะเมื่อคุณกรอกค่าจริง

ทำไมแอปเดียวต้องมีสองช่องทางจ่ายเงิน

Stripe ครอบคลุม 99% ของผู้ใช้ — บัตรเครดิต / Apple Pay / Google Pay สำหรับ flow $9.99/เดือนประสบการณ์ไม่มีใครสู้ได้

x402 ครอบคลุม 1% ที่เหลือของคนสำคัญ: ผู้ใช้ crypto-native ผู้ใช้นานาชาติที่อยาก stablecoin และนักพัฒนาที่เขียน agent อัตโนมัติ (agent ของเขาต้องจ่ายเงินเองเพื่อเข้าถึง API เสียเงิน — 402 ถูกออกแบบเพื่อสิ่งนี้)

การตัดสินใจผลิตภัณฑ์สำคัญ: tier รายเดือนไม่ได้ x402 $9.99/เดือนกับเซ็นกระเป๋าทุกเดือน UX แย่มาก เราเปิด x402 เฉพาะรายปี $99 ที่ friction กระจายเหลือปีละครั้ง

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

if เดียวใน _plan_card.html.erb ตัดสินใจว่า card ใดแสดงปุ่ม USDC ง่ายแค่นั้น


ให้ Claude เชื่อมต่อการจ่ายเงิน — checklist เต็ม:

  1. เข้าใจสองโปรโตคอลแยกกันก่อนให้ Claude เขียนโค้ด Stripe ไปทาง hosted Checkout + webhook; x402 ไปทาง HTTP 402 + กระเป๋าบราวเซอร์ — อย่าคาดหวังว่า Claude จะแยกเอง
  2. method record อยู่ใน model Controller เรียกบรรทัดเดียว; field mapping ทั้งหมดใน model เพิ่ม inclusion: { in: %w[stripe x402] } เป็นประตู type
  3. สำหรับโปรโตคอลใหม่ เขียนมือก่อน ค่อยสลับเป็น gem diff ระหว่างสองอันคือสื่อเรียนของคุณ
  4. สลับ chain/mode ที่ runtime ผ่าน Rails.env Stripe test/live, x402 base-sepolia/base — ทั้งหมดสลับผ่าน Rails.env.production?
  5. ทุก button_to ของ Stripe ต้องมี data-turbo=false ไม่งั้น Turbo กลืน 302 cross-origin เงียบ ๆ
  6. หลังติดตั้ง gem ใด ๆ ที่มี Stimulus controller รัน bin/importmap json importmap ทิ้ง pin ที่ไฟล์ vendor ขาดหายเงียบ ๆ
  7. ใส่เครื่องหมายอัญประกาศรอบ credentials ที่ดูเหมือน prefix ตัวเลข 0x... / true / 07 ไม่งั้นจะโดน parsing พิเศษของ YAML

ส่วนที่ยากของการให้ Claude เขียนการจ่ายเงินไม่ใช่ตัวโปรโตคอล — คือขอบเขตการ integrate (Turbo vs Stripe, importmap vs gem, YAML vs ที่อยู่กระเป๋า) นั่นคือช่วงที่คุณต้องนั่งเฝ้าเอง