Free

ترك Claude يدمج طريقتي دفع: Stripe + x402

بروتوكولان مختلفان تماماً في تطبيق واحد — Checkout مستضاف من Stripe مع webhook، وHTTP 402 مع محفظة المتصفح من x402. ثلاثة فخاخ صامتة، بنية واحدة تشغل المسارين معاً.


ربطت مؤخراً كلاً من Stripe (بطاقات/عملات قانونية) وx402 (USDC على EVM on-chain) في خطة Pro لـ how2claude. ترك Claude يكتب تكاملات لبروتوكولين مختلفين تماماً — أحدهما Checkout مستضاف + webhook، والآخر HTTP 402 + محفظة المتصفح — استغرق جلسة مسائية كاملة. تعثرت في ثلاثة إخفاقات صامتة، وانتهيت ببنية تشغّل المسارين معاً.

هذا ليس درس "كيف تدمج Stripe" — تلك متوفرة في كل مكان. الجزء الممتع: كيف يجلس البروتوكولان جنباً إلى جنب، أين يصعب على Claude أن لا يخطئ، وفي أي لحظات يجب أن تجلس أنت بنفسك وتراقب.


نموذجا دفع

البُعد Stripe x402
المُشغِّل button_to → إعادة توجيه إلى checkout.stripe.com POST /x402/subscribe → يُرجع HTTP 402
إجراء المستخدم إدخال البطاقة في صفحة Stripe المستضافة التوقيع في محفظة المتصفح
تسليم النتيجة webhook (checkout.session.completed) إعادة الطلب مع header X-PAYMENT، الـ gem يسوّي بشكل متزامن
البيانات التي أحتاج لحفظها payment_intent_id + amount_total tx_hash + payer + amount
تعقيد البروتوكول الـ SDK يفعل كل شيء يحتاج مصافحة بروتوكول viem + x402-fetch

مختلفان جوهرياً: Stripe يدفع المستخدم إلى صفحته الخاصة وأنت تتحقق فقط من webhook عند عودته؛ x402 يبقى كلياً على نطاقك من البداية إلى النهاية، يجري مصافحة البروتوكول على طبقة HTTP.

هذا التمييز يقود كل قرار معماري أدناه.

نحّف الـ controllers — ادفع طرق record إلى الـ model

في البداية كانت الـ controllers محشوة بربط الحقول:

# ❌ الإصدار المبكر
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... عشرة أسطر أو أكثر من ربط الحقول
  )
end

كلا المسارين يحفظان Purchase + Subscription، لكن الحقول مختلفة تماماً. ربط الحقول في الـ controller يعني أن كل مسار ينسخ منطق الربط.

الترحيل (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

أربع طرق إجمالاً: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. الـ controller يصبح سطراً واحداً:

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

Claude رائع في هذا النوع من العمل: سيربط بإخلاص كل حقل، ويضيف اختبارات، ويضيف validates :provider, inclusion: { in: %w[stripe x402] }. البشر يميلون إلى "اجعله يعمل أولاً"، فيظل ربط الحقول مبعثراً عبر الـ controllers، لا يفلت أبداً.

الإيقاع: اكتب يدوياً أولاً، ثم انتقل إلى الـ gem

في b2f0333 جعلت Claude يكتب تكامل x402 الأول يدوياً — ثلاث فئات:

  • X402::PaymentHandler — بناء 402 requirements، فك تشفير header PAYMENT-SIGNATURE
  • X402::FacilitatorClient — تغليف /verify + /settle لـ x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — اكتشاف header 402، إرجاع PAYMENT-REQUIRED

449 سطراً، يعمل، اختبارات تجتاز.

بعد ست ساعات (9f3e239) جعلته يستبدل كل شيء بـ gem x402-rails (بروتوكول v1، وضع غير-تفاؤلي). حذفت الفئات الثلاث؛ الـ controllers الآن تستخدم DSL x402_paywall(amount:) وتقرأ من request.env["x402.payment"] وrequest.env["x402.settlement_result"].

الإيقاع مهم: الكتابة اليدوية أولاً تجعلك تفهم البروتوكول، ثم الـ gem يحررك. إن بدأت بالـ gem، Claude يكتب بناءً على وثائق الـ gem ولن تعرف ما الذي يوجد فعلاً في header 402 أو ما يفعله /settle. حين ينكسر شيء (دائماً ينكسر شيء)، ليس لديك أرضية للتنقيح.

هذا النمط يصلح لأي بروتوكول/خدمة جديدة: اجعل Claude يكتب يدوياً مرة، اخضّر الاختبارات، ثم اجعله يستبدل بالـ gem. الـ diff بين الاثنين هو مادتك الدراسية.

قلّب السلسلة وقت التشغيل بـ Rails.env، لا يدوياً عند النشر

x402 initializer (config/initializers/x402.rb) يضع القاعدة hardcoded:

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 قبل المتابعة، لنلتقط tx_hash بشكل متزامن
end

نفس الكود: dev يعمل base-sepolia (رموز اختبار مجانية)، prod يعمل base mainnet. لا شيء يتغير عند النشر. (هذا المبدأ جاء من المقال السابق ترك Claude ينشر إلى الإنتاج — أي شيء يختلف بين dev وprod، قلّبه عبر Rails.env.)

سطر optimistic = false مهم: وضع الـ gem الافتراضي optimistic يمرر الطلب ويوفّق لاحقاً؛ نطفئه لأننا نريد settlement_result.transaction (الـ tx_hash) قبل أن ترجع الـ action، لكتابته متزامناً إلى صف Purchase. صف Purchase بدون tx_hash لا قيمة له للمستخدم — يريد أن ينقر ليرى المعاملة على 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. صفر كود واجهة أمامية على جانبك.

جانب x402 (93746d8) احتاج Stimulus controller:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // تحميل كسول — لا تنفخ 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. تحميل كسول لـ viem + x402-fetch (يُجلب فقط من jsdelivr عند أول نقرة على الزر). هذان الحزمتان كبيرتان معاً؛ تجميعهما في vendor يجبر كل مستخدم غير دافع على التحميل. التحميل الكسول يحوّله إلى "حمّل فقط إذا أردت الدفع".
  2. استخدم نتيجة eth_requestAccounts، لا selectedAddress. selectedAddress deprecated ومعظم المحافظ تُرجع قيمة قديمة. أول إصدار من Claude استخدم selectedAddress (وفقاً لوثائق MDN)؛ غيّرته.

شيء آخر: عدّد أكواد الأخطاء. رفض المحفظة للتوقيع هو 4001، السلسلة الخاطئة تحتاج تبديل هي CHAIN_SWITCH، الدفع المطلوب هو PAYMENT_REQUIRED. لا تطابق سلاسل ضد error.message — المحافظ تكتب بطرق مختلفة ولا يمكنك كتابة اختبارات ضدها.

الفخ #1: button_to + Turbo يبتلع 302 من Stripe بصمت

التزام 527f700 هو الذي قضيت نصف ساعة أحدّق في المتصفح لأجده.

العَرَض: انقر زر Subscribe في /pricing، لا يحدث شيء. لا خطأ في console، لا خطأ شبكة. سجل Rails يُظهر 200 يُعيد 302 → checkout.stripe.com/c/pay/cs_xxx. المتصفح لا يتحرك.

السبب: button_to يُنشئ <form method="post">، وTurbo يعترض إرسال النموذج، يعامل الاستجابة كـ TURBO_STREAM. TURBO_STREAM لا يتبع 302 cross-origin. الاستجابة تُبتلع بصمت من قبل 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 في بطاقة "current plan" في /pricing (يقفز إلى billing.stripe.com)، وManage Subscription في /accounts. كل واحد حصل على data-turbo=false واختبار انحدار.

عندما طلبت من Claude تنقيح هذا، استكشف ثلاث اتجاهات خاطئة: تكوين Stripe (لا)، قائمة بيضاء redirect_uri (لا)، CORS (اتجاه خاطئ). تعارض Turbo/Stripe ليس في وثائق Stripe ولا وثائق Turbo — وتقريباً لا شيء عنه في بيانات تدريب Claude. هذا النوع من الفخاخ تصطاده فقط برؤية 302 يعود في علامة تبويب network ثم تسأل نفسك "إذن لماذا لم يتبعه المتصفح؟".

الفخ #2: Failed to resolve module specifier 'x402-fetch'

بعد تثبيت gem x402-rails، console المتصفح:

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

لكنني أحمّل صراحةً عبر await import("https://esm.run/[email protected]") — URL كاملة — فلماذا "resolve module specifier"؟

السبب الجذري: gem x402-rails يأتي مع Stimulus controller يعتمد على @hotwired/stimulus. ثبّتت تلك الحزمة في config/importmap.rb، لكن ملف vendor المقابل vendor/javascript/@hotwired--stimulus.js لم يُحمَّل أبداً. importmap يلاحظ أن الملف مفقود ويُسقط الـ pin بصمت من الـ importmap المُولَّد. الذي يفشل ليس x402-fetch الخاص بي؛ هو Stimulus controller الخاص بالـ gem. الخطأ يفقّع إلى أقرب import.

التشخيص: bin/importmap json يُخرج الـ importmap المُولَّد فعلياً. قارن بـ config/importmap.rb — أي pin غائب من json يعني أن ملف vendor الخاص به لم يُحمَّل.

الإصلاح: bin/importmap pin @hotwired/stimulus لجلب الملف فعلياً.

Claude لا يشغّل bin/importmap json انعكاسياً كفحص سلامة بعد تثبيت gem. هذا عليك. إن كنت تستخدم importmap، بعد تثبيت أي gem يأتي مع Stimulus controllers، شغّل bin/importmap json مرة وأكّد أن لا pin أُسقط بصمت.

الفخ #3: YAML يُفسّر عنوان محفظة 0x... كعدد صحيح

في credentials:

x402:
  wallet_address: 0x1234abcd...

عندما يحمّل Rails هذا، YAML يحلل 0x1234abcd... كـ عدد صحيح (hex literal). بحلول الوقت الذي يصل فيه X402.configure إلى القيمة، النوع مكسور، والـ gem يُنتج paywall requirements غريبة.

إصلاح بحرف واحد: أضف اقتباس.

x402:
  wallet_address: "0x1234abcd..."

Claude لم يضع اقتباساً عند كتابة قالب credentials — بيانات تدريبه مليئة بأمثلة YAML بسلاسل عارية. تتفعّل فقط حين يكون البادئة بالصدفة 0x / true / false / أرقاماً. هذا النوع من فخاخ "تحليل YAML الخاص" يتفعّل فقط حين تملأ قيماً حقيقية.

لماذا يحتاج تطبيق واحد إلى مساري دفع

Stripe يغطي 99% من المستخدمين — بطاقة ائتمان / Apple Pay / Google Pay. لتدفق $9.99/شهر، التجربة لا تُهزم.

x402 يغطي الـ 1% المتبقي من الأشخاص المهمين: مستخدمي العملات المشفرة الأصليين، المستخدمين الدوليين الذين يريدون عملات مستقرة، والمطورين الذين يكتبون agents آلية (والتي تحتاج agentاتهم القدرة على الدفع للوصول إلى APIs مدفوعة — لذلك صُمم 402).

قرار منتجي حاسم: الخطة الشهرية لا تحصل على x402. $9.99/شهر مع توقيع محفظة كل شهر هي تجربة مستخدم فظيعة. نفعّل x402 فقط في $99 السنوية، حيث يتوزع الاحتكاك إلى مرة واحدة في السنة.

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

if واحد في _plan_card.html.erb يحدد أي البطاقات تُظهر زر USDC. بهذه البساطة.


ترك Claude يدمج المدفوعات — قائمة كاملة:

  1. افهم البروتوكولين بشكل منفصل قبل أن تدع Claude يكتب الكود. Stripe يذهب hosted Checkout + webhook؛ x402 يذهب HTTP 402 + محفظة المتصفح — لا تتوقع من Claude أن يميّزها بمفرده.
  2. طرق record تنتمي إلى الـ model. Controllers تستدعي سطراً واحداً؛ كل ربط الحقول في الـ model. أضف inclusion: { in: %w[stripe x402] } كبوابة نوع.
  3. للبروتوكولات الجديدة، اكتب يدوياً أولاً، ثم استبدل بالـ gem. الـ diff بين الاثنين هو مادتك الدراسية.
  4. قلّب السلسلة/الوضع وقت التشغيل عبر 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 controllers، شغّل bin/importmap json. importmap يُسقط بصمت pins ملفات vendor الخاصة بها مفقودة.
  7. اقتبس أي credentials تبدو كبادئات أرقام. 0x... / true / 07 تخضع لتحليل YAML خاص.

الجزء الصعب من ترك Claude يكتب المدفوعات ليس البروتوكولات نفسها — بل حدود التكامل (Turbo ضد Stripe، importmap ضد gem، YAML ضد عنوان المحفظة). هذه هي اللحظات التي يجب أن تجلس فيها بنفسك.