Stripe con Rails: pagamenti reali con carta, dall'inizio alla fine
È appena partita una nuova serie: Stripe con Rails — integrare Stripe Checkout in un’app Ruby on Rails, da un vero rails new fino a un flusso di pagamento funzionante e testato. Riusa volutamente lo stesso dominio Product/Order di Nexi XPay con Rails, così ciò che cambia davvero tra un gateway europeo “in casa” e uno americano moderno, developer-first, emerge nel codice stesso, non solo a parole.

Il modello mentale in una riga
Il “Pagamento Semplice” di Nexi è un POST di un form firmato, inviato dal browser: la tua app Rails costruisce un form con campi nascosti e un MAC calcolato a mano, e il browser lo invia direttamente ai server di Nexi. Il tuo backend non parla mai direttamente con Nexi per avviare un pagamento.
Stripe Checkout inverte questo schema. Il tuo server chiama prima Stripe:
session = Stripe::Checkout::Session.create(
mode: "payment",
line_items: [{
price_data: {
currency: "eur",
product_data: { name: "Corso di cucina siciliana" },
unit_amount: 3500 # centesimi, per unità — mai il totale
},
quantity: 1
}],
client_reference_id: order.order_number,
success_url: stripe_success_url,
cancel_url: stripe_cancel_url
)
redirect_to session.url, allow_other_host: true
Stripe restituisce un URL, e solo dopo mandi lì il browser. Nessun MAC da calcolare a mano — la richiesta stessa è autenticata da un header Bearer sk_test_... che il gem ufficiale allega da solo.
Stessa conferma a due canali, firma diversa
Entrambi i gateway risolvono lo stesso problema nello stesso modo: un redirect verso il tuo sito è solo UX e può semplicemente non avvenire mai (scheda chiusa, browser crashato), quindi non può mai essere ciò che marca un ordine come pagato. L’unica fonte di verità è un messaggio che il gateway manda direttamente al tuo server:
| Nexi | Stripe | |
|---|---|---|
| Canale asincrono | Notifica S2S, firmata con MAC | Evento webhook, firmato HMAC |
| Canale sincrono (solo UX) | Redirect browser + campo esito |
Redirect browser a success_url/cancel_url |
| Il tuo riferimento ordine | codTrans |
client_reference_id |
Questa è esattamente la forma dell’episodio 2: verificare la firma di un webhook Stripe con Stripe::Webhook.construct_event, messa a confronto diretto con la verifica MAC SHA1 scritta a mano nella serie Nexi.
Cosa ha costruito davvero l’episodio 1
Non uno schizzo — una vera app Rails 8.1, Postgres, Tailwind, taggata git per episodio, con la propria suite Minitest:
test "create_session sends the price in cents, not decimal euros" do
order = orders(:pending_order)
captured_params = nil
fake_session = OpenStruct.new(id: "cs_test_123", url: "https://checkout.stripe.com/pay/cs_test_123")
Stripe::Checkout::Session.stub(:create, ->(params) { captured_params = params; fake_session }) do
StripeCheckoutService.create_session(
order: order, success_url: "https://example.com/success", cancel_url: "https://example.com/cancel"
)
end
line_item = captured_params[:line_items].first
assert_equal 3500, line_item[:price_data][:unit_amount]
assert_equal "eur", line_item[:price_data][:currency]
end
11 test, nessuna chiamata Stripe reale, e lungo il percorso due bug veri causati da gem pubblicate proprio quella sera (json 3.0.2 che rompe ActiveSupport::JSON.decode, minitest 6.0.6 che rimuove Minitest::Mock dal gem core) — più un bug di Turbo Drive genuinamente sottile: un redirect cross-origin ingoiato in silenzio da un submit di form basato su fetch(), diagnosticato dal vivo nel browser prima della correzione.
Leggi l’episodio 1
Configurazione e avvio di un pagamento copre tutto per intero — il meccanismo di Stripe Checkout, il modello dati, lo StripeCheckoutService, una spiegazione da zero di cos’è davvero un webhook e perché Checkout ne ha bisogno, ed entrambi i bug nel dettaglio.
Cosa viene dopo
- Episodio 2 — gestire l’esito: verificare la firma del webhook, mappare
checkout.session.completedsuOrder#mark_paid!, testare in locale con la Stripe CLI invece di ngrok. - Episodio 3 — verso la produzione: una vera traccia di audit per gli eventi in arrivo, gestione di
checkout.session.expiredepayment_intent.payment_failed, non solo il percorso felice.
Codice companion: github.com/AntoninoScaffidi/stripe-with-rails, taggato episode-1.