DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Simple Stripe Billing for Rails

This articles was originally published on Build a SaaS with Ruby on Rails


This is the second article of Build 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 is really needed initially is customer_id and status. 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 mode Exit fullscreen mode

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

class CreateSubscriptions < ActiveRecord::Migration[8.0]
  def change
    create_table :subscriptions do |t|
      t.belongs_to :user, null: false, foreign_key: true
      t.string :customer_id, null: false
      t.string :subscription_id, null: false
      t.datetime :cancel_at, null: true
      t.datetime :current_period_end_at, null: false
      t.string :status, null: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

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

Then the created subscription file:

class Subscription < ApplicationRecord
  belongs_to :user

  enum status: %w[incomplete active trialing canceled incomplete_expired past_due unpaid].index_by(&:itself), _default: "incomplete"

  delegate :email, to: :user
end
Enter fullscreen mode Exit 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 using Stripe'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.rb
class BillingsController < ApplicationController
  def create
    session = 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_to session.url, status: 303, allow_other_host: true
  end
end
Enter fullscreen mode Exit fullscreen mode

It uses the status code 303 (See Other), and allow_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 mode Exit 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 mode Exit fullscreen mode

But let's not stop here. There are two parts missing to be fully up and running: access to the Billing 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 to skip 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 the BillingsController by adding the edit action:

# app/controllers/billings_controller.rb
class BillingsController < ApplicationController
  # …

  def edit
    session = Stripe::BillingPortal::Session.create({
      customer: Current.user.subscription.customer_id,
      return_url: root_url
    })

    redirect_to session.url, status: 303, allow_other_host: true
  end
end
Enter fullscreen mode Exit fullscreen mode

Then extend the previously added route:

resource :billings, only: %w[create edit]
Enter fullscreen mode Exit 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 mode Exit 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 a POST 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 mode Exit fullscreen mode

Then the controller:

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_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]

  def create
  end

  private

  def verify_webhook_signature
    begin
      Stripe::Webhook.construct_event(
        request.body.read,
        request.env["HTTP_STRIPE_SIGNATURE"],
        ENV["STRIPE_SIGNING_SECRET"]
      )
    rescue Stripe::SignatureVerificationError
      return false
    end

    true
  end

  def render_empty_json
    render json: {}
  end

  def webhook_exists?
    Webhook.find_by(source_id: params[:id], source: "stripe")
  end

  def event_type = event.data[:type]

  def event = Webhook.create(webhook_params)

  def webhook_params
    {
      source: "stripe",
      source_id: params[:id],
      data: params.except(:source, :controller, :action)
    }
  end
end
Enter fullscreen mode Exit 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 the signing 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 Webhook source source_id status data:jsonb
Enter fullscreen mode Exit fullscreen mode

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

class CreateWebhooks < ActiveRecord::Migration[7.0]
  def change
    create_table :webhooks do |t|
      t.string :source, null: false
      t.string :source_id, null: false
      t.string :status, null: false
      t.jsonb :data, null: false, default: {} # postgres specific!

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And then the Webhook model:

class Webhook < ApplicationRecord
  enum :status, %w[pending completed].index_by(&:itself), default: "pending"
end
Enter fullscreen mode Exit fullscreen mode

All that is needed: an enum for the status with a default value of pending.

Now let's update the create method in the WebhooksController. 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):

The checkout_session_completed event is the one sent after payment was successful.

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  # …

  def create
    {
      "checkout.session.completed": checkout_session_completed
    }[event_type]
  end

  private

  def checkout_session_completed
    subscription Stripe::Subscription.retrieve(event.data.object.subscription)

    User.find(event.data.object.client_reference_id).tap do |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)
      )
    end

    event.completed!
  end
end
Enter fullscreen mode Exit 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 install Stripe'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:

class User < ApplicationRecord
  # …

  def subscribed? = Subscription.exists?(user: self, status: %w[active trialing])
end
Enter fullscreen mode Exit fullscreen mode

Now within controllers (or your authorization setup of choice), you can do Current.user.subscribed? and get either true or false.

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)