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. Диф между двумя — ваш учебный материал.

Переключай цепочку через 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, не та цепочка нужен 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. Диф между двумя — твой учебный материал.
  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 адрес кошелька). Это моменты, когда ты должен сидеть там сам.