สองโปรโตคอลที่ต่างกันสิ้นเชิงในแอปเดียว — 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 ถูกยัดด้วย 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 โดยไม่เคยหลุดออกมา
ใน b2f0333 ผมให้ Claude เขียน integration x402 ครั้งแรกด้วยมือ — สามคลาส:
X402::PaymentHandler — สร้าง 402 requirements, decode header PAYMENT-SIGNATUREX402::FacilitatorClient — ห่อ /verify + /settle ของ x402.org/facilitatorapp/controllers/concerns/content_gate.rb — ตรวจ header 402 คืน PAYMENT-REQUIRED449 บรรทัด ทำงาน 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 ระหว่างสองอันคือสื่อเรียนรู้ของคุณ
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" ฝั่ง 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)
})
// ...
}
สองสิ่งที่น่าสังเกต:
eth_requestAccounts อย่าใช้ selectedAddress selectedAddress deprecated และกระเป๋าส่วนใหญ่คืนค่าล้าสมัย Claude เวอร์ชันแรกใช้ selectedAddress (ตาม MDN docs); ผมเปลี่ยนอีกเรื่อง: enumerate รหัส error กระเป๋าปฏิเสธการเซ็นคือ 4001 chain ผิดต้อง switch คือ CHAIN_SWITCH ต้องจ่ายเงินคือ PAYMENT_REQUIRED อย่า string-match error.message — กระเป๋าใช้คำต่างกันและคุณเขียน test กับมันไม่ได้
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 แล้วถามตัวเอง "แล้วทำไมบราวเซอร์ไม่ตาม?"
หลังติดตั้ง 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 ที่ถูกทิ้งเงียบ
ใน 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 เต็ม:
inclusion: { in: %w[stripe x402] } เป็นประตู typeRails.env.production?data-turbo=false ไม่งั้น Turbo กลืน 302 cross-origin เงียบ ๆbin/importmap json importmap ทิ้ง pin ที่ไฟล์ vendor ขาดหายเงียบ ๆ0x... / true / 07 ไม่งั้นจะโดน parsing พิเศษของ YAMLส่วนที่ยากของการให้ Claude เขียนการจ่ายเงินไม่ใช่ตัวโปรโตคอล — คือขอบเขตการ integrate (Turbo vs Stripe, importmap vs gem, YAML vs ที่อยู่กระเป๋า) นั่นคือช่วงที่คุณต้องนั่งเฝ้าเอง