Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Rails Designer
Rails Designer

Posted on • Edited on • Originally published atrailsdesigner.com

     

Simple Stripe Billing for Rails

This (second) article was originally published onBuild a SaaS with Ruby on Rails.


It doesn't take a lot of words to tell you need to bill your users if you want to build a (side) business with your software. Without it, it is just a side project.

I want to go over my default billing implementation with Stripe. It is minimal by design, but has served me really well. If you run, or want to run, a typical SaaS app this might be a great starting point for you too. As always with articles in this section,the code is available in this repo. The first commit is a vanilla Rails 8 app and the code from the authentication generator.

The described code is a slimmed down version. My actual implementation is more extensive, but the essence is the same.

Every SaaS app I started had only one plan (monthly and yearly) upon launch. There's no reason to complicate things, you are still finding the exact needed features as you do not have “Product Market Fit” yet. So keep it simple with your core value in one plan. That said: I keep things flexible enough so it's easy to add more plans when needed.

I want to write as little code as needed and use Stripe's low-code solution as much as possible. Over the years Stripe has improved how to get started collecting payments. No more need to add a JavaScript snippet, add a public key and so on.All that is needed are the ~123 lines of code below.

Let's first look at the data model. You don't need to store a lot:

  • customer_id, to redirect to Stripe's billing portal;
  • subscription_id, so it's possible to make changes to it programmatically;
  • cancel_at, to query for cancellations (and possibly send retention emails);
  • current_period_end_at, send custom emails before period end;
  • status, store current state of their subscription.

All that isreally needed initially iscustomer_id andstatus. But I've done this enhough times to also store the other attributes.

Data model

Let's create the model for it:

rails generate model Subscription user:belongs_to customer_id subscription_id cancel_at:datetime current_period_end_at:datetime status
Enter fullscreen modeExit fullscreen mode

Easy enough. Let's tweak the created migration like so:

classCreateSubscriptions<ActiveRecord::Migration[8.0]defchangecreate_table:subscriptionsdo|t|t.belongs_to:user,null:false,foreign_key:truet.string:customer_id,null:falset.string:subscription_id,null:falset.datetime:cancel_at,null:truet.datetime:current_period_end_at,null:falset.string:status,null:falset.timestampsendendend
Enter fullscreen modeExit fullscreen mode

All that is changed is setting null values to either true or false. I like to explicitly setnull: false too, just to show future me (or someone else it was done on purpose).

Then the createdsubscription file:

classSubscription<ApplicationRecordbelongs_to:userenumstatus:%w[incomplete active trialing canceled incomplete_expired past_due unpaid].index_by(&:itself),_default:"incomplete"delegate:email,to: :userend
Enter fullscreen modeExit fullscreen mode

Just some basics here; nothing special. With that done, we are getting closer already. Now the next step is to get the user's payment details. As mentioned I want to write as little code as possible, so I am usingStripe's Checkout portal.

Create subscription

Let's first add Stripe's gem to the app:bundle add stripe. Lots of this code relies on the functionality from the Stripe gem.

Then create the controller that will do the redirect:

# app/controllers/billings_controller.rbclassBillingsController<ApplicationControllerdefcreatesession=Stripe::Checkout::Session.create({success_url:root_url,cancel_url:root_url,client_reference_id:Current.user.id,customer_email:Current.user.email,mode:"subscription",subscription_data:{trial_period_days:30# You can choose any number of trial days here},line_items:[{quantity:1,price:"price_1234"# add your price id from Stripe here}]})redirect_tosession.url,status:303,allow_other_host:trueendend
Enter fullscreen modeExit fullscreen mode

It uses the status code 303 (See Other), andallow_other_host: true allows redirection to external domains.

Then to be able to link it up, let's add this to the routes:

resource:billings,only:%w[create]
Enter fullscreen modeExit fullscreen mode

You know can now already redirect your users to your Stripe's checkout page:

<%=button_to"Subscribe",billings_path,method: :post,data:{turbo:false}%>
Enter fullscreen modeExit fullscreen mode

But let's not stop here. There are two parts missing to be fully up and running: access to theBilling Portal, so your users can manage their subscription, and webhooks, so everything stays in sync.

Before I show how to set up those, I like to encourage you toskip both initially when you launch. Why? You can manually, via the console:

  • activate the subscription upon payment/trial notification;
  • do the reverse if they cancel.

You'd be surprised how lenient people will be when you tell your real story (early stage and so on). You might even get praise for it!

Not adventurous enough? Let's continue and add the code for the billing portal first as it's the easiest.

Manage subscription

Let's extend theBillingsController by adding the edit action:

# app/controllers/billings_controller.rbclassBillingsController<ApplicationController# …defeditsession=Stripe::BillingPortal::Session.create({customer:Current.user.subscription.customer_id,return_url:root_url})redirect_tosession.url,status:303,allow_other_host:trueendend
Enter fullscreen modeExit fullscreen mode

Then extend the previously added route:

resource:billings,only:%w[create edit]
Enter fullscreen modeExit fullscreen mode

With this button, you can redirect your users to their billing portal:

<%=button_to"Manage your subscription",edit_billings_path,data:{turbo:false}%>
Enter fullscreen modeExit fullscreen mode

Now your users can manage their subscription: cancel it, upgrade/downgrade, download invoices and whatever feature you have enabled.

Edging ever closer, but still an important part and the most involved feature is missing: webhooks. It keeps the data on Stripe in sync with your app, meaning:

  • creating the subscription;
  • set the status, cancel_at and current_period_end_at attributes.

All webhooks are is aPOST request to an URL of your app, like:app.example.com/webhooks. In the body of it is a payload with all the details you need to set up the subscription (or cancel it).

First the route:

post"webhooks",to:"webhooks#create"
Enter fullscreen modeExit fullscreen mode

Then the controller:

# app/controllers/webhooks_controller.rbclassWebhooksController<ApplicationControllerskip_before_action:verify_authenticity_token,only:%w[create]before_action:verify_webhook_signature,only:%w[create]before_action:render_empty_json,if: :webhook_exists?,only:%w[create]defcreateendprivatedefverify_webhook_signaturebeginStripe::Webhook.construct_event(request.body.read,request.env["HTTP_STRIPE_SIGNATURE"],ENV["STRIPE_SIGNING_SECRET"])rescueStripe::SignatureVerificationErrorreturnfalseendtrueenddefrender_empty_jsonrenderjson:{}enddefwebhook_exists?Webhook.find_by(source_id:params[:id],source:"stripe")enddefevent_type=event.data[:type]defevent=Webhook.create(webhook_params)defwebhook_params{source:"stripe",source_id:params[:id],data:params.except(:source,:controller,:action)}endend
Enter fullscreen modeExit fullscreen mode

Already lots going on, so let's go over the important bits.

verify_webhook_signature, because your webhook endpoints is effectively public, Stripe sends along a signature. This is built using the thesigning secret, the raw body payload and a timestamp. Upon receiving the webhook, these values are checked. Another notable thing is the creation of a Webhook record in the database. The reason I store them is multipurpose: allows me to check if I already received the webhook, rerun because of a bug the logic did not run or debug if I found a bug. I have a background job that purges old webhooks ; you don't need to keep them around for long.

Let's create that model:

rails g model Webhooksourcesource_id status data:jsonb
Enter fullscreen modeExit fullscreen mode

Similar to the Subscription migration, let's also update this one:

classCreateWebhooks<ActiveRecord::Migration[7.0]defchangecreate_table:webhooksdo|t|t.string:source,null:falset.string:source_id,null:falset.string:status,null:falset.jsonb:data,null:false,default:{}# postgres specific!t.timestampsendendend
Enter fullscreen modeExit fullscreen mode

And then the Webhook model:

classWebhook<ApplicationRecordenum:status,%w[pending completed].index_by(&:itself),default:"pending"end
Enter fullscreen modeExit fullscreen mode

All that is needed: anenum for the status with a default value ofpending.

Now let's update the create method in theWebhooksController. I prefer to create separate objects for each event, so they are easy to test, but here, for the sake of simplicity, I'll add them inline (it's essentially the same logic):

Thecheckout_session_completed event is the one sent after payment was successful.

# app/controllers/webhooks_controller.rbclassWebhooksController<ApplicationController# …defcreate{"checkout.session.completed":checkout_session_completed}[event_type]endprivatedefcheckout_session_completedsubscriptionStripe::Subscription.retrieve(event.data.object.subscription)User.find(event.data.object.client_reference_id).tapdo|user|user.create_subscription(customer_id:event.data.object.customer,subscription_id:subscription.id,status:subscription.status,cancel_at:Time.at(subscription.cancel_at),current_period_end_at:Time.at(subscription.current_period_end))endevent.completed!endend
Enter fullscreen modeExit fullscreen mode

I am using a hash to map the incoming event type (checkout.session.completed) to the method (checkout_session_completed) that does the work. This makes it easy to extend it with other events.

Based on your use-case you might want to listen for more events. The ones you really need with this set up:

  • checkout.session.completed, already done above;
  • customer.subscription.update, when a subscription gets renewed;
  • customer.subscription.deleted, when a subscription gets cancelled.

When testing the end to end code, be sure to installStripe's CLI. You can set it up to listen for webhooks from your Stripe test account (stripe listen --forward-to localhost:3000/webhooks --events=checkout.session.completed).

Things still left to do

This article describes the bare essentials for getting Stripe billing set up with your Rails app and to move from a nice side-project to a nice (side-)business.

Let's add a simple method for easier (authorization) checks:

classUser<ApplicationRecord# …defsubscribed?=Subscription.exists?(user:self,status:%w[active trialing])end
Enter fullscreen modeExit fullscreen mode

Now within controllers (or your authorization setup of choice), you can doCurrent.user.subscribed? and get eithertrue orfalse.

There are a few things left to do still:

  • extend webhook events;
  • authorization for check out- or billing portal;
  • other authorization checks for your features.

And that's all you need to collect payments from your users. Stripe has come along way from the early days and while Stripe has gotten quite more complicated with its offering, getting the basics up and running is as simple as described here.

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

I help teams around the world make their Rails apps a bit nicer to maintain and look at. 🎨 Also kickstart SaaS' in a month. 👷 And built a Rails UI Components library, used by 1000+ developers. 🚀
  • Location
    Amsterdam, The Netherlands
  • Joined

More fromRails Designer

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp