Free

Нехай Claude інтегрує два способи оплати: Stripe + x402

Два цілком різні протоколи в одному застосунку — хостинг-Checkout від Stripe + webhook, і HTTP 402 + браузерний гаманець від x402. Три тихі провали, одна архітектура для обох шляхів.


Нещодавно я підключив до Pro-тарифу how2claude і Stripe (карти/фіат), і x402 (USDC on-chain в EVM). Змусити Claude написати інтеграції для двох цілком різних протоколів — один хостинг-Checkout + webhook, другий HTTP 402 + браузерний гаманець — зайняло цілий вечір сесії. Налетів на три тихі провали і врешті отримав архітектуру, що жене обидва канали разом.

Це не туторіал "як інтегрувати Stripe" — таких повно. Цікаве: як два протоколи вживаються поруч, де Claude найлегше падає ниць, і в які моменти доведеться сидіти й дивитися самому.


Дві парадигми оплати

Вимір Stripe x402
Тригер button_to → редирект на checkout.stripe.com POST /x402/subscribe → повертає HTTP 402
Дія користувача Введення картки на хостинг-сторінці Stripe Підпис у браузерному гаманці
Доставка результату webhook (checkout.session.completed) Запит повторюється з заголовком X-PAYMENT, gem закриває синхронно
Дані для збереження payment_intent_id + amount_total tx_hash + payer + amount
Складність протоколу SDK робить усе Потрібен handshake viem + x402-fetch

Принципово різні: Stripe виштовхує користувача на свою сторінку, а ви перевіряєте лише webhook при поверненні; x402 залишається на вашому домені з початку до кінця, виконуючи handshake протоколу на рівні 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,
    # ... з десяток рядків мапінгу
  )
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 — побудова 402 requirements, декодування заголовка PAYMENT-SIGNATURE
  • X402::FacilitatorClient — обгортка /verify + /settle для x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — виявлення заголовка 402, віддача PAYMENT-REQUIRED

449 рядків, працює, тести зелені.

Через шість годин (9f3e239) я змусив замінити все це gem-ом x402-rails (протокол v1, не-оптимістичний режим). Видалив ті три класи; контролери тепер використовують DSL x402_paywall(amount:) і читають з request.env["x402.payment"] та request.env["x402.settlement_result"].

Темп важить: спершу писати руками — щоб зрозуміти протокол, потім gem вас звільняє. Якщо почнете з gem, Claude пише за його документацією, а ви гадки не маєте, що насправді в заголовку 402 або що робить /settle. Коли щось зламається (щось завжди ламається), немає ґрунту для дебагінгу.

Цей патерн працює для будь-якого нового протоколу/сервісу: нехай Claude напише руками один раз, приведе тести до зеленого, потім замінить на gem. Diff між ними — ваш навчальний матеріал.

Переключай chain через Rails.env у рантаймі, не руками при деплої

Ініціалізатор x402 (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  # чекаємо settle facilitator перед продовженням, щоб синхронно отримати tx_hash
end

Той же код: dev ганяє base-sepolia (безкоштовні тестові токени), prod — base mainnet. Нічого не змінювати при деплої. (Цей принцип прийшов з попередньої статті Віддаємо продакшен-деплой Claude — усе, що відрізняється між dev і prod, перемикається через Rails.env.)

Рядок optimistic = false важливий: оптимістичний режим gem за замовчуванням пропускає запит і звіряється пізніше; ми вимикаємо, бо хочемо отримати 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-контролер:

// 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, а не selectedAddress. selectedAddress застарів і більшість гаманців повертають застаріле значення. Перша версія Claude використовувала selectedAddress (за документацією MDN); я переключив.

Ще одне: перелічуй коди помилок. Гаманець відхилив підпис — 4001, не та chain потрібен switch — CHAIN_SWITCH, потрібна оплата — PAYMENT_REQUIRED. Не роби string-match за error.message — гаманці формулюють по-різному і тести проти цього не напишеш.

Пастка #1: button_to + Turbo тихо ковтає 302 від Stripe

Коміт 527f700 — той, заради якого я півгодини витріщався в браузер.

Симптом: клік по кнопці Subscribe на /pricing — нічого не трапляється. Ні помилок консолі, ні помилок мережі. Лог Rails показує 200 з 302 → checkout.stripe.com/c/pay/cs_xxx. Браузер не рухається.

Причина: button_to генерує <form method="post">, а Turbo перехоплює submit форми та обробляє відповідь як 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 %>

Три кнопки зачеплені: Subscribe на /pricing, кнопка Manage на картці "поточний план" на /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, консоль браузера:

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

Але я ж явно роблю ліниву загрузку через await import("https://esm.run/[email protected]") — повний URL — чому "resolve module specifier"?

Корінна причина: gem x402-rails тягне з собою Stimulus-контролер, що залежить від @hotwired/stimulus. Я пінив цей пакет у config/importmap.rb, але відповідний vendor-файл vendor/javascript/@hotwired--stimulus.js ніколи не завантажувався. importmap помічає відсутність файлу і тихо викидає пін із згенерованого importmap. Те, що падає, — не мій x402-fetch; це Stimulus-контролер gem-а. Помилка піднімається до найближчого import.

Діагностика: bin/importmap json виводить реально згенерований importmap. Порівняйте з config/importmap.rb — будь-який пін, відсутній у json, означає, що його vendor-файл не завантажено.

Виправлення: bin/importmap pin @hotwired/stimulus, щоб дійсно стягти файл.

Claude не запускає bin/importmap json рефлекторно як sanity-чек після встановлення gem. Це на тобі. Якщо використовуєш importmap, після встановлення будь-якого gem зі Stimulus-контролерами — запусти bin/importmap json один раз і підтверди, що жоден пін не був тихо викинутий.

Пастка #3: YAML інтерпретує 0x... адресу гаманця як ціле

У credentials:

x402:
  wallet_address: 0x1234abcd...

Коли Rails завантажує це, YAML парсить 0x1234abcd... як ціле (hex-літерал). До моменту, коли 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% важливих людей: крипто-нативних користувачів, міжнародних користувачів, яким потрібні стейблкоїни, і розробників автоматизованих агентів (агенти мають уміти платити самі за доступ до платних API — для цього й спроектовано 402).

Ключове продуктове рішення: місячний тариф не отримує x402. $9.99/міс з підписом гаманця щомісяця — жахливий UX. Вмикаємо 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-методи живуть у моделі. Контролери викликають один рядок; весь мапінг полів у моделі. Додай inclusion: { in: %w[stripe x402] } як ворота типу.
  3. Для нових протоколів — спершу руками, потім переключайся на gem. Diff між двома — твій навчальний матеріал.
  4. Перемикай chain/mode у рантаймі через Rails.env. Stripe test/live, x402 base-sepolia/base — усе перемикається через Rails.env.production?.
  5. Кожному Stripe button_to потрібен data-turbo=false. Інакше Turbo тихо ковтає cross-origin 302.
  6. Після встановлення будь-якого gem зі Stimulus-контролерами запусти bin/importmap json. importmap тихо викидає піни, чиї vendor-файли відсутні.
  7. Бери в лапки будь-які credentials, що виглядають як числові префікси. 0x... / true / 07 інакше потрапляють під спеціальний парсинг YAML.

Важкі частини роботи Claude з платежами не в самих протоколах — а в інтеграційних межах (Turbo vs Stripe, importmap vs gem, YAML vs адреса гаманця). Це моменти, коли ти маєш сидіти там сам.