Rails Stripe Billing: Abonnementen, Webhooks, Proratie en Dunning die de Productie Overleven
Rails Stripe billing goed gedaan. Abonnementslevenscyclus, idempotente webhooks, proratie, dunning, en de valkuilen die SaaS-founders stil duizenden kosten.
Een SaaS-founder stuurde me op een dinsdagmiddag zijn Stripe-dashboard door en vroeg waarom zijn maandomzet ‘s nachts met elf procent was gedaald. Het getal in Stripe klopte. Het getal in zijn Rails-app was elf procent te hoog, omdat de app abonnementen stilletjes als active markeerde zodra een checkout-sessie was voltooid, terwijl Stripe diezelfde abonnementen twee weken later stilletjes als past_due had gemarkeerd toen de tweede factuur faalde. Niemand in het team had het door, omdat niemand invoice.payment_failed had aangesloten. Rails Stripe billing is een van die integraties die er klaar uitziet zodra je de checkout-knop hebt opgeleverd, en in werkelijkheid voor dertig procent klaar is.
Na negentien jaar Rails en een decennium founders helpen hun billing-stack te ontwarren, behandel ik Stripe inmiddels op dezelfde manier als de database — saaie, dragende infrastructuur die je in één keer goed moet doen, omdat elke retrofit migratiepijn is. Deze post is de Rails Stripe billing-stack die ik standaard oplever: het datamodel, de webhook-handler, de abonnementslevenscyclus, de proratielogica en de dunning-flow die klanten laat betalen nadat hun pas is verlopen.
Wat Rails Stripe Billing in Werkelijkheid Moet Afhandelen
De checkout-knop is het makkelijke deel. Het lastige is alles wat na de eerste succesvolle betaling gebeurt, en Stripe maakt dat deel met opzet lastig omdat billing oprecht ingewikkeld is. Een productieklare Rails Stripe billing-integratie moet minstens zeven verschillende gebeurtenissen aankunnen: een geslaagde eerste betaling, een mislukte betaling, een verlenging, een planwissel met proratie, een opzegging per periode-einde, een directe opzegging en een terugbetaling. Elk van die zaken arriveert als een of meer webhook-events, en elk daarvan kan twee keer komen, in de verkeerde volgorde, of zes uur te laat.
De valkuil waar de meeste teams in trappen is Stripe behandelen als een request-response API. Ze roepen Stripe::Checkout::Session.create aan, redirecten de gebruiker, markeren het abonnement als actief bij terugkomst, en gaan live. Dat werkt op het happy path en faalt de eerste keer dat een pas wordt geweigerd bij een verlenging, de eerste keer dat een klant midden in de cyclus downgradet, en de eerste keer dat Stripe een webhook opnieuw aflevert omdat jouw app een 500 teruggaf. De oplossing is om Stripe als de bron van waarheid voor de billing-staat te behandelen en je Rails-database als een gecachete read-side projectie van wat Stripe zegt. Elke wijziging op de projectie komt van een webhook, nooit van een controller-actie.
Het Datamodel voor Rails Stripe Billing
Begin met drie tabellen: customers, subscriptions en een webhook event-log. Weersta de verleiding om gewoon een stripe_status-kolom op je users-tabel te zetten en het daarmee af te doen — dat groei je ontgroeien zodra een klant twee abonnementen heeft, of een abonnement gepauzeerd is.
# db/migrate/20260628000001_create_stripe_billing.rb
class CreateStripeBilling < ActiveRecord::Migration[8.0]
def change
create_table :stripe_customers do |t|
t.references :account, null: false, foreign_key: true, index: { unique: true }
t.string :stripe_customer_id, null: false
t.string :default_payment_method
t.string :email
t.timestamps
end
add_index :stripe_customers, :stripe_customer_id, unique: true
create_table :stripe_subscriptions do |t|
t.references :account, null: false, foreign_key: true
t.string :stripe_subscription_id, null: false
t.string :stripe_price_id, null: false
t.string :status, null: false # trialing, active, past_due, canceled, ...
t.integer :quantity, null: false, default: 1
t.datetime :current_period_start
t.datetime :current_period_end
t.datetime :cancel_at
t.datetime :canceled_at
t.boolean :cancel_at_period_end, null: false, default: false
t.timestamps
end
add_index :stripe_subscriptions, :stripe_subscription_id, unique: true
add_index :stripe_subscriptions, [:account_id, :status]
create_table :stripe_webhook_events do |t|
t.string :stripe_event_id, null: false
t.string :event_type, null: false
t.string :status, null: false, default: "received"
t.jsonb :payload, null: false, default: {}
t.string :error_message
t.datetime :processed_at
t.timestamps
end
add_index :stripe_webhook_events, :stripe_event_id, unique: true
add_index :stripe_webhook_events, [:event_type, :created_at]
end
end
De unieke index op stripe_event_id is de goedkoopste idempotentie-check die je ooit zult schrijven. Stripe blijft elke webhook opnieuw aanbieden tot je een 2xx teruggeeft, en “opnieuw” omvat ook “stuur hetzelfde event-id vier uur later nog een keer omdat je vorige respons een timeout had”. Eén unieke constraint plus een ON CONFLICT DO NOTHING-insert en je hebt idempotentie gratis.
De cancel_at_period_end-boolean verdient aparte aandacht. Stripe representeert “stoppen aan het einde van de huidige periode” als een vlag op een verder actief abonnement, niet als een eigen status, en de dag waarop dat ertoe doet is de dag dat een klant je op dag negenentwintig mailt met de mededeling dat hij het abonnement toch wil houden. Als je die vlag niet in je projectie hebt, kun je geen “je abonnement stopt op de 30e” in de UI tonen, en kun je geen “toch maar niet”-knop in de app aanbieden.
Stripe Webhooks Ontvangen in Rails
De webhook-controller heeft drie taken: de handtekening verifiëren, het event idempotent vastleggen en doorgeven aan een background processor. Probeer niet slim te zijn en het event inline te verwerken — Stripe geeft je tien seconden om een 2xx terug te geven, en je User.find_by plus drie downstream API-calls gaan dat op een maandagochtend onvermijdelijk overschrijden.
# app/controllers/stripe/webhooks_controller.rb
class Stripe::WebhooksController < ActionController::API
WEBHOOK_SECRET = Rails.application.credentials.dig(:stripe, :webhook_secret)
def create
payload = request.raw_post
signature = request.headers["Stripe-Signature"]
event = Stripe::Webhook.construct_event(payload, signature, WEBHOOK_SECRET)
record = StripeWebhookEvent.create!(
stripe_event_id: event.id,
event_type: event.type,
payload: event.to_hash
)
Stripe::ProcessWebhookJob.perform_later(record.id)
head :ok
rescue ActiveRecord::RecordNotUnique
head :ok
rescue Stripe::SignatureVerificationError, JSON::ParserError
head :bad_request
end
end
Twee details verdienen hun plek. Het opvangen van ActiveRecord::RecordNotUnique en teruggeven van :ok is wat het endpoint echt idempotent maakt — een dubbele aflevering is geen fout, het is het systeem dat doet wat het moet doen. :bad_request teruggeven bij handtekeningsfalen (niet :unauthorized) is wat een verkeerd geconfigureerde retry-loop tegenhoudt om je endpoint eeuwig plat te leggen. Dezelfde idempotentiepatronen gelden voor elke webhook-integratie; mocht je hem nog niet gelezen hebben, mijn Rails webhook processing post gaat dieper in op handtekeningverificatie en replay-bescherming.
De Abonnementslevenscyclus Verwerken
De background job is waar de eigenlijke logica leeft. Stripe stuurt tientallen event-types, maar voor een typisch SaaS-abonnement geef je maar om een stuk of zes, zeven. Route elk event naar een handler, houd de handlers klein, en schrijf ze zodanig dat ze twee keer kunnen draaien zonder iets te slopen.
# app/jobs/stripe/process_webhook_job.rb
class Stripe::ProcessWebhookJob < ApplicationJob
queue_as :stripe
retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 5
HANDLERS = {
"customer.subscription.created" => Stripe::Handlers::SubscriptionUpserted,
"customer.subscription.updated" => Stripe::Handlers::SubscriptionUpserted,
"customer.subscription.deleted" => Stripe::Handlers::SubscriptionDeleted,
"invoice.payment_succeeded" => Stripe::Handlers::PaymentSucceeded,
"invoice.payment_failed" => Stripe::Handlers::PaymentFailed,
"customer.updated" => Stripe::Handlers::CustomerUpdated
}.freeze
def perform(event_id)
event = StripeWebhookEvent.find(event_id)
return if event.status == "processed"
handler = HANDLERS[event.event_type]
return event.update!(status: "ignored", processed_at: Time.current) unless handler
handler.new(event.payload).call
event.update!(status: "processed", processed_at: Time.current)
rescue => e
event.update!(status: "failed", error_message: e.message)
raise
end
end
De SubscriptionUpserted-handler doet het werk waar de meeste teams het mis hebben. Zowel created als updated lopen door hetzelfde pad, omdat Stripe soms updated vóór created stuurt als een webhook-aflevering vertraagd is en er in die tussentijd een planwissel plaatsvindt. Upsert vanuit de Stripe-payload, ga niet uit van een volgorde.
# app/services/stripe/handlers/subscription_upserted.rb
class Stripe::Handlers::SubscriptionUpserted
def initialize(payload)
@subscription = payload.dig("data", "object")
end
def call
customer = StripeCustomer.find_by!(stripe_customer_id: @subscription["customer"])
record = StripeSubscription.find_or_initialize_by(
stripe_subscription_id: @subscription["id"]
)
record.assign_attributes(
account_id: customer.account_id,
stripe_price_id: @subscription["items"]["data"].first["price"]["id"],
status: @subscription["status"],
quantity: @subscription["items"]["data"].first["quantity"],
current_period_start: Time.at(@subscription["current_period_start"]),
current_period_end: Time.at(@subscription["current_period_end"]),
cancel_at: timestamp(@subscription["cancel_at"]),
canceled_at: timestamp(@subscription["canceled_at"]),
cancel_at_period_end: @subscription["cancel_at_period_end"]
)
record.save!
Account.find(customer.account_id).refresh_entitlements!
end
private
def timestamp(value)
value && Time.at(value)
end
end
refresh_entitlements! is de regel die billing terugkoppelt aan je product. Hij draait de logica die bepaalt tot welke features een account toegang heeft op basis van de huidige abonnementsstatus. Houd hem zuiver — lees uit stripe_subscriptions, schrijf naar een features-cache of een gememoriseerde methode, roep Stripe nooit binnenin aan. Als je multi-tenant feature gating draait, gaat mijn Rails Pundit multi-tenant authorization post over de policy-kant van datzelfde probleem.
Proratie: Het Deel dat Iedereen Verrast
Proratie is wat Stripe doet als een klant midden in een cyclus van plan wisselt. Het is ook het deel waarbij founders ontdekken dat Stripe en hun Rails-app het oneens zijn over hoeveel een klant nog moet betalen. Het standaardgedrag is create_prorations — Stripe rekent direct het verschil uit tussen de niet-gebruikte tijd van het oude plan en de prorated kosten van het nieuwe plan, en zet dat als regel op de volgende factuur. Dat is meestal wat je wilt, maar het is zelden wat je klanten verwachten.
De twee knoppen die ertoe doen zijn proration_behavior en billing_cycle_anchor. Zet de eerste op "create_prorations" voor upgrades (reken het verschil direct af op de volgende factuur) en "none" voor downgrades die pas in moeten gaan aan het einde van de periode. Zet billing_cycle_anchor op "unchanged" tenzij je wilt dat de verlengdatum van de klant reset, wat bijna geen enkele SaaS daadwerkelijk wil.
# app/services/stripe/change_plan.rb
class Stripe::ChangePlan
def initialize(subscription:, new_price_id:, upgrade:)
@subscription = subscription
@new_price_id = new_price_id
@upgrade = upgrade
end
def call
Stripe::Subscription.update(
@subscription.stripe_subscription_id,
items: [{
id: stripe_item_id,
price: @new_price_id
}],
proration_behavior: @upgrade ? "create_prorations" : "none",
billing_cycle_anchor: "unchanged",
payment_behavior: "error_if_incomplete"
)
end
private
def stripe_item_id
Stripe::Subscription.retrieve(@subscription.stripe_subscription_id).items.data.first.id
end
end
payment_behavior: "error_if_incomplete" is de regel die voorkomt dat een downgrade stilletjes slaagt terwijl de pas van de klant kapot is. Zonder die regel accepteert Stripe de wijziging, maakt een factuur aan die de klant niet kan betalen, en toont jouw projectie het verkeerde plan totdat de dunning-flow het oplost of het abonnement opzegt. Faal snel op de API-call, en je UI kan een fatsoenlijke foutmelding tonen.
Dunning: Klanten Laten Betalen Nadat Hun Pas is Verlopen
Dunning is het beleefde branchewoord voor “de pas van de klant is geweigerd en nu moeten we erachteraan”. Bij een B2C-app is de juiste zet om het volledig uit te besteden aan Stripe Billing’s Smart Retries — configureer drie retry-pogingen verspreid over twee weken, stel de dunning-mailtemplate in, en laat Stripe zijn werk doen. Bij een B2B-app wil je doorgaans meer controle, want de klant die een geüpdatete factuur nodig heeft voor de crediteurenadministratie is dezelfde klant wiens kaart op naam staat van het secretariaat van de CFO en niet van de CEO.
In beide gevallen zijn de twee webhook-events om af te handelen invoice.payment_failed en customer.subscription.updated met een status van past_due of unpaid. De eerste vertelt je dat één poging is mislukt, de tweede vertelt je dat Stripe zijn retries heeft opgebruikt. Gebruik de eerste voor in-app banners en notificaties en de tweede voor het daadwerkelijk downgraden van het account.
# app/services/stripe/handlers/payment_failed.rb
class Stripe::Handlers::PaymentFailed
def initialize(payload)
@invoice = payload.dig("data", "object")
end
def call
customer = StripeCustomer.find_by!(stripe_customer_id: @invoice["customer"])
account = Account.find(customer.account_id)
BillingMailer.payment_failed(
account: account,
amount_due: @invoice["amount_due"],
hosted_url: @invoice["hosted_invoice_url"],
next_attempt: timestamp(@invoice["next_payment_attempt"])
).deliver_later
account.update!(billing_alert_active: true)
end
private
def timestamp(value)
value && Time.at(value)
end
end
Twee productielessen. Stuur hosted_invoice_url in de mail, geen link naar je eigen app — Stripe host een betaalupdate-pagina die SCA, 3D Secure en bankredirects correct afhandelt, en daar kom jij niet aan tippen. Voer de feitelijke downgrade door op basis van de subscription status update, niet op basis van de payment failure, want Stripe kan bij poging drie alsnog slagen en je wilt niet dat het account in de tussentijd is gedegradeerd.
Rails Stripe Billing Testen Zonder Gek te Worden
Twee tools maken billing-tests dragelijk. stripe-mock draait Stripe’s API lokaal met hetzelfde OpenAPI-schema als productie, zodat je service-objects Stripe::Subscription.create kunnen aanroepen in tests zonder echte netwerkcalls. Het stripe trigger-commando van de Stripe CLI vuurt productie-vormige webhook-events af op je lokale server, wat de enige eerlijke manier is om handtekeningverificatie end-to-end te testen.
# spec/services/stripe/handlers/subscription_upserted_spec.rb
require "rails_helper"
RSpec.describe Stripe::Handlers::SubscriptionUpserted do
let(:account) { create(:account) }
let(:customer) { create(:stripe_customer, account: account, stripe_customer_id: "cus_test") }
it "upserts the subscription idempotently" do
payload = JSON.parse(file_fixture("stripe/subscription_created.json").read)
2.times { described_class.new(payload).call }
expect(StripeSubscription.where(account: account).count).to eq(1)
expect(account.reload).to be_active_subscription
end
end
De handler twee keer aanroepen in dezelfde test is geen paranoia — het is de goedkoopst mogelijke rooktest voor de eigenschap die er het meest toe doet. Als je handler niet idempotent is, faalt de test voordat de webhook ooit in productie aankomt.
Veelgestelde Vragen over Rails Stripe Billing
Hoe moet ik Stripe customer-id’s opslaan in Rails?
Zet het stripe_customer_id op een aparte stripe_customers-tabel die met een unieke foreign key aan je accounts (of users, of organizations)-tabel hangt. Zet het niet rechtstreeks op het user-record — vroeg of laat heb je B2B-accounts waarin meerdere gebruikers één billing-relatie delen, en op dat moment refactoren is pijnlijk. Indexeer stripe_customer_id uniek zodat webhook-handlers customers in één query kunnen opzoeken.
Moet ik Stripe webhook-handtekeningen verifiëren in Rails?
Ja, altijd. Zonder handtekeningverificatie kan iedereen die je webhook-URL raadt abonnementen in je database als betaald markeren. Gebruik Stripe::Webhook.construct_event met de raw request body en je STRIPE_WEBHOOK_SECRET, en wijs events die niet verifiëren af met een 400. Combineer handtekeningverificatie met een unieke index op stripe_event_id om zowel echtheid als idempotentie te krijgen.
Wat is het verschil tussen Stripe Checkout en Stripe Billing in Rails?
Stripe Checkout is een gehoste pagina voor het innen van de eerste betaling en het aanmaken van een abonnement — het is de makkelijke route van “ik heb een Rails-app” naar “ik heb mijn eerste betalende klant”. Stripe Billing is de abonnementsengine die verlengingen, proratie, facturen en dunning afhandelt nadat die eerste betaling binnen is. De meeste Rails-apps gebruiken Checkout voor de initiële signup en leunen op de webhook-events van Billing voor alles wat daarna komt.
Hoe handel ik Stripe abonnementsproratie af in Rails?
Laat Stripe het rekenwerk doen. Wanneer een klant van plan wisselt, roep Stripe::Subscription.update aan met proration_behavior: "create_prorations" voor upgrades en "none" voor downgrades die pas aan het einde van de periode in moeten gaan. Zet billing_cycle_anchor: "unchanged" zodat de verlengdatum niet reset. Lees de resulterende factuurregels uit via de invoice.created-webhook als je het prorated bedrag in je eigen UI wilt tonen voordat de klant wordt afgeschreven.
Rails Stripe billing is een van die problemen waarbij het verschil tussen “werkt op het happy path” en “werkt in productie” grofweg twee weken aan webhook-afhandeling, idempotentie en dunning-logica is. Bouw de stack één keer goed, behandel Stripe als de bron van waarheid, en je maandomzetcijfers in Rails komen eindelijk overeen met de cijfers in je Stripe-dashboard.
Hulp nodig bij het aansluiten van Stripe Billing op je Rails-app, of bij het ontwarren van een billing-integratie die de werkelijkheid is kwijtgeraakt? TTB Software is gespecialiseerd in Rails SaaS-infrastructuur voor founders en groeiende teams. Wij doen dit al negentien jaar.
Related Articles
Rails LLM Kostentracking: Spend per Tenant, Budgetlimieten en Realtime Quota-handhaving
Rails LLM kostentracking die een verrassing van 40k overleeft. Tokenboekhouding per tenant, budgetlimieten, quota-han...
Rails Counter Cache: N+1 COUNT-queries elimineren zonder productievalkuilen
Rails counter cache elimineert N+1 COUNT-queries op has_many-associaties. Stel hem juist in, reset achterhaalde telle...
Rails ActionMailer Productiegids: E-mailbezorging, Moderne API's en Waterdicht Testen
Rails ActionMailer in productie: Resend, Postmark of SendGrid, betrouwbare inboxbezorging, bounceverwerking, deliver_...