Free

Niech Claude zintegruje dwa systemy płatności: Stripe + x402

Dwa zupełnie różne protokoły w jednej aplikacji — hostowany Checkout Stripe + webhook i HTTP 402 + portfel przeglądarki x402. Trzy ciche pułapki, jedna architektura dla obu torów.


Niedawno podpiąłem zarówno Stripe (karty/fiat), jak i x402 (USDC on-chain EVM) do tieru Pro how2claude. Kazanie Claude'owi napisać integracje dla dwóch zupełnie różnych protokołów — jeden hostowany Checkout + webhook, drugi HTTP 402 + portfel przeglądarki — zajęło całą wieczorną sesję. Wpadłem w trzy ciche pułapki i skończyłem z architekturą, która pędzi oba tory razem.

To nie jest tutorial "jak zintegrować Stripe" — takich jest wszędzie. Ciekawe kawałki: jak oba protokoły mieszczą się obok siebie, gdzie Claude najłatwiej się wywraca, i w jakich momentach musisz sam siedzieć i patrzeć.


Dwa paradygmaty płatności

Wymiar Stripe x402
Wyzwalacz button_to → przekierowanie do checkout.stripe.com POST /x402/subscribe → zwraca HTTP 402
Akcja użytkownika Wpisuje kartę na hostowanej stronie Stripe Podpisuje w portfelu przeglądarki
Dostarczenie wyniku webhook (checkout.session.completed) Żądanie ponawiane z nagłówkiem X-PAYMENT, gem rozlicza synchronicznie
Dane do zapisania payment_intent_id + amount_total tx_hash + payer + amount
Złożoność protokołu SDK robi wszystko Potrzebny handshake viem + x402-fetch

Fundamentalnie różne: Stripe wypycha użytkownika na własną stronę, a ty weryfikujesz tylko webhook przy powrocie; x402 zostaje całkowicie na twojej domenie od początku do końca, robiąc handshake protokołu w warstwie HTTP.

Ta różnica napędza każdą decyzję architektoniczną poniżej.

Odchudź controllery — pchaj metody record do modelu

Na początku controllery były nafaszerowane mapowaniem pól:

# ❌ Wczesna wersja
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... tuzin linii mapowania pól
  )
end

Oba tory persystują Purchase + Subscription, ale pola są totalnie różne. Mapowanie w controllerze oznacza, że każdy tor kopiuje logikę mapowania.

Migracja (9f3e239) pchnęła to do modelu:

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

Łącznie cztery metody: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Controller staje się jedną linijką:

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

Claude jest świetny w takiej pracy: posłusznie zmapuje każde pole, doda testy i validates :provider, inclusion: { in: %w[stripe x402] }. Ludzie skłaniają się do "najpierw niech zadziała" i mapowanie pól kończy rozsypane po controllerach, bez szansy na ucieczkę.

Rytm: najpierw ręcznie, potem migracja do gem

W b2f0333 kazałem Claude'owi napisać pierwszą integrację x402 ręcznie — trzy klasy:

  • X402::PaymentHandler — budowa 402 requirements, dekodowanie nagłówka PAYMENT-SIGNATURE
  • X402::FacilitatorClient — owijka /verify + /settle dla x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — wykrywanie nagłówka 402, zwracanie PAYMENT-REQUIRED

449 linii, działa, testy zielone.

Sześć godzin później (9f3e239) kazałem podmienić wszystko na gem x402-rails (protokół v1, tryb nie-optymistyczny). Usunąłem te trzy klasy; controllery używają teraz DSL x402_paywall(amount:) i czytają z request.env["x402.payment"] oraz request.env["x402.settlement_result"].

Rytm się liczy: pisanie ręczne pierwsze daje zrozumienie protokołu, potem gem cię uwalnia. Jeśli zaczniesz od gem, Claude pisze pod jego dokumentację, a ty nie masz pojęcia, co tak naprawdę jest w nagłówku 402 ani co robi /settle. Gdy coś pęknie (zawsze coś pęknie), nie masz gruntu do debugowania.

Ten wzorzec działa dla każdego nowego protokołu/serwisu: niech Claude napisze ręcznie raz, dopchaj testy do zielonego, potem niech podmieni na gem. Diff między dwoma to twój materiał do nauki.

Przełączaj chain przez Rails.env w runtime, nie ręcznie przy deploy

Initializer x402 (config/initializers/x402.rb) hardkoduje regułę:

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  # czekaj na settle od facilitatora przed kontynuacją, żeby chwycić tx_hash synchronicznie
end

Ten sam kod: dev chodzi na base-sepolia (darmowe tokeny testowe), prod chodzi na base mainnet. Nic do zmiany przy deployu. (Ta zasada przyszła z poprzedniego artykułu Niech Claude wdraża na produkcję — cokolwiek różni się między dev a prod, przerzucaj przez Rails.env.)

Linia optimistic = false ma znaczenie: domyślny tryb optymistyczny gem przepuszcza żądanie i rozlicza potem; wyłączamy, bo chcemy settlement_result.transaction (tx_hash) zanim action zwróci, żeby zapisać synchronicznie do wiersza Purchase. Wiersz Purchase bez tx_hash jest bezwartościowy dla użytkownika — będzie chciał kliknąć i zobaczyć transakcję na BaseScan.

Frontend: jedna strona hostowana, druga robiona ręcznie

"Frontend" po stronie Stripe to jedna linia:

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

Użytkownik klika, przeglądarka skacze do checkout.stripe.com. Zero kodu frontendowego po twojej stronie.

Strona x402 (93746d8) potrzebowała controllera Stimulus:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Lazy-load — nie rozdymaj bundle vendora
  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)
  })
  // ...
}

Dwie rzeczy warte uwagi:

  1. Lazy-load viem + x402-fetch (brany z jsdelivr tylko przy pierwszym kliknięciu przycisku). Te dwa pakiety razem są duże; zbundlowanie ich do vendora zmusiłoby każdego niepłacącego użytkownika do pobrania. Lazy-load zmienia to w "ściągnij tylko jak chcesz zapłacić".
  2. Używaj wyniku eth_requestAccounts, nie selectedAddress. selectedAddress jest deprecated i większość portfeli zwraca nieaktualną wartość. Pierwsza wersja Claude użyła selectedAddress (według dokumentów MDN); zmieniłem.

Jeszcze jedno: enumeruj kody błędów. Portfel odrzucił podpis to 4001, zła chain potrzebna zmiana to CHAIN_SWITCH, wymaga płatności to PAYMENT_REQUIRED. Nie rób string-match na error.message — portfele formułują różnie i nie napiszesz testów przeciwko temu.

Pułapka #1: button_to + Turbo cicho połyka 302 Stripe'a

Commit 527f700 znalazłem po pół godzinie wpatrywania się w przeglądarkę.

Objaw: klik na przycisk Subscribe na /pricing, nic się nie dzieje. Żadnego błędu konsoli, żadnego błędu sieci. Log Rails pokazuje 200 z 302 → checkout.stripe.com/c/pay/cs_xxx. Przeglądarka się nie rusza.

Przyczyna: button_to generuje <form method="post">, a Turbo przechwytuje submit formularza i traktuje odpowiedź jak TURBO_STREAM. TURBO_STREAM nie podąża za 302 cross-origin. Odpowiedź jest cicho połknięta przez Turbo; strona stoi w miejscu.

Fix:

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

Trzy przyciski dotknięte: Subscribe na /pricing, Manage na karcie "obecny plan" na /pricing (skacze do billing.stripe.com), i Manage Subscription na /accounts. Każdy dostał data-turbo=false i test regresyjny.

Gdy kazałem Claude'owi to zdebugować, zbadał trzy złe kierunki: konfiguracja Stripe (nie), białą listę redirect_uri (nie), CORS (zły kierunek). Konflikt Turbo/Stripe nie jest w dokumentacji Stripe ani w dokumentacji Turbo — a w danych treningowych Claude prawie nic o tym nie ma. Takie pułapki łapie się tylko oglądając 302 wracający w zakładce network i pytając siebie "to czemu przeglądarka za nim nie poszła?".

Pułapka #2: Failed to resolve module specifier 'x402-fetch'

Po zainstalowaniu gem x402-rails, konsola przeglądarki:

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

Ale ja wyraźnie robię lazy-load przez await import("https://esm.run/[email protected]") — pełny URL — czemu więc "resolve module specifier"?

Pierwotna przyczyna: gem x402-rails ciągnie ze sobą controller Stimulus zależny od @hotwired/stimulus. Spinowałem ten pakiet w config/importmap.rb, ale odpowiedni plik vendora vendor/javascript/@hotwired--stimulus.js nigdy nie został pobrany. importmap zauważa, że plik brakuje i cicho zrzuca pin ze wygenerowanego importmapa. To, co zawodzi, to nie mój x402-fetch; to controller Stimulus gem-a. Błąd bąbelkuje do najbliższego import.

Diagnoza: bin/importmap json wypisuje rzeczywiście wygenerowany importmap. Porównaj z config/importmap.rb — każdy pin nieobecny w json oznacza, że jego plik vendora nie jest pobrany.

Fix: bin/importmap pin @hotwired/stimulus, żeby faktycznie ściągnąć plik.

Claude nie uruchamia odruchowo bin/importmap json jako sanity check po zainstalowaniu gem. To na tobie. Jeśli używasz importmap, po zainstalowaniu dowolnego gem z controllerami Stimulus uruchom bin/importmap json raz i potwierdź, że żaden pin nie został cicho zrzucony.

Pułapka #3: YAML interpretuje adres portfela 0x... jako integer

W credentials:

x402:
  wallet_address: 0x1234abcd...

Gdy Rails to ładuje, YAML parsuje 0x1234abcd... jako integer (literal hex). Zanim X402.configure dotrze do wartości, typ jest zepsuty, a gem produkuje dziwne paywall requirements.

Fix jednoznakowy: dodaj cudzysłowy.

x402:
  wallet_address: "0x1234abcd..."

Claude nie dał cudzysłowów pisząc szablon credentials — jego dane treningowe są pełne przykładów YAML z gołymi stringami. Odpala się tylko gdy prefiks przypadkowo jest 0x / true / false / cyfry. Ten typ pułapki "parsowania specjalnego YAML" odpala się tylko gdy wpiszesz prawdziwe wartości.

Dlaczego jedna aplikacja potrzebuje dwóch torów płatności

Stripe pokrywa 99% użytkowników — karta kredytowa / Apple Pay / Google Pay. Dla flow $9.99/miesiąc doświadczenie jest nie do pobicia.

x402 pokrywa pozostały 1% ważnych ludzi: użytkowników crypto-native, międzynarodowych użytkowników chcących stablecoinów, i developerów piszących automatyzowane agenty (których agenty muszą móc same płacić za dostęp do płatnych API — do tego został zaprojektowany 402).

Kluczowa decyzja produktowa: tier miesięczny nie dostaje x402. $9.99/miesiąc z podpisem portfela co miesiąc to okropne UX. Włączamy x402 tylko na rocznym $99, gdzie tarcie amortyzuje się do raz na rok.

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

Jeden if w _plan_card.html.erb decyduje, które karty pokazują przycisk USDC. Tak po prostu.


Niech Claude zintegruje płatności — pełna lista kontrolna:

  1. Zrozum dwa protokoły osobno przed dopuszczeniem Claude'a do pisania kodu. Stripe idzie z hostowanym Checkout + webhook; x402 idzie z HTTP 402 + portfelem przeglądarki — nie oczekuj, że Claude sam je rozdzieli.
  2. Metody record należą do modelu. Controllery wołają jedną linię; całe mapowanie pól w modelu. Dodaj inclusion: { in: %w[stripe x402] } jako bramę typu.
  3. Dla nowych protokołów — najpierw ręcznie, potem przerzuć na gem. Diff między dwoma to twój materiał do nauki.
  4. Przerzucaj chain/mode w runtime przez Rails.env. Stripe test/live, x402 base-sepolia/base — wszystko przerzucane przez Rails.env.production?.
  5. Każdy button_to Stripe'a potrzebuje data-turbo=false. Inaczej Turbo cicho połyka cross-origin 302.
  6. Po zainstalowaniu dowolnego gem z controllerami Stimulus uruchom bin/importmap json. importmap cicho zrzuca piny, których pliki vendora są nieobecne.
  7. Daj cudzysłowy wokół dowolnych credentials, które wyglądają jak prefiksy liczbowe. 0x... / true / 07 dostaną inaczej specjalne parsowanie YAML.

Trudne części dawania Claude'owi pisania płatności to nie same protokoły — to granice integracyjne (Turbo vs Stripe, importmap vs gem, YAML vs adres portfela). To są momenty, w których musisz siedzieć sam.