Galih Muhammad
Rubyist

Rubyist

Easy SaaS subscription flow with Stripe Checkout in Rails

Easy SaaS subscription flow with Stripe Checkout in Rails

Get an end-to-end integration between subscription and registration up quickly utilizing Stripe's Pricing Table and Checkout objects.

Galih Muhammad's photo
Galih Muhammad
·Sep 18, 2022·

11 min read

Table of contents

What we are going to build is a Stripe checkout integration for subscription flow of a SaaS app like this:

stripe-saas-susbcription-flow.gif

So the idea of the sequence is:

→ User views landing page

→ user clicks on one of the plans on pricing table

→ user gets redirected to the Stripe Checkout page

→ User fills the form and completes the checkout

→ user gets redirected to the registration form on our website

→ user adds password for authentication

→ user registered and authenticated, with subscription already provisioned.

In this guide, we’re assuming we’ll sell a subscription of our web app in three tiers and only serve customers with USD and EUR as the currencies.

All the code in this article is available on this github repo.

Modelling user and account

On this guide, the assumption is that we are using devise for our authentication,

rails g devise:install

that means by running the above command, we’ll already have users table and User model.

To model the Stripe Customer object

rails g model Account email stripe_customer_id
rails g model AccountUser user:references account:references role
class AccountUser < ApplicationRecord
    belongs_to :account
    belongs_to :user
end
class User < ApplicationRecord
    has_many :account_users
    has_many :accounts, through: :account_users
    # "devise" line added automatically by devise, no big deal. But notice we remove :confirmable
    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :lockable, :trackable
end
class Account < ApplicationRecord
    has_many :account_users
    has_many :users, through: :account_users, source: :user
end

Modeling the product and plan

For you not familiar with Stripe, “Product” object refers to something that you charge your customer for, basically things you sell be it a tangible goods like clothes or intangible things like subscription. Plan (or Price in Stripe) on the other hand, is merely the pricing unit for the Product, usually depicted in form of currency (USD, EUR, etc) and interval (monthly, quarterly, yearly).

For the Product, since there won’t be many changes we expect to make for this kind of records, let’s just model it into static values using a ruby class. You can make this in form of Ruby PORO, but to make it easier and cleaner, let’s use ActiveHash gem for this.

Gemfile

gem 'active_hash'

Add the above line to your Gemfile then run bundle install.

After that create a file called product.rb inside the app/models/ directory.

class Product < ActiveHash::Base
  fields :stripe_product_id, :name, :tier, :unit_amounts
  create id: 1, stripe_product_id: 'LITE', name: 'Lite', tier: 'lite', unit_amounts: {
        month: { eur: 1000, usd: 1200 },
        year: { eur: 10000, usd: 12000 }
    }
  create id: 2, stripe_product_id: 'PRO', name: 'Pro', tier: 'pro', unit_amounts: {
        month: { eur: 2000, usd: 2400 },
        year: { eur: 20000, usd: 24000 }
    }
  create id: 3, stripe_product_id: 'PREMIUM', name: 'Premium', tier: 'premium', unit_amounts: {
        month: { eur: 4000, usd: 4800 },
        year: { eur: 40000, usd: 48000 }
    }
end

Now for the pricing object which is the Plan, we can do similar thing like the Product, but for this we can predict there will be more dynamism to the data, hence we go for full-blown database table. So let’s run Rails scaffold for generating the model.

rails g model Plan product_id interval currency nickname unit_amount stripe_price_id

The scaffold command will automaticall generate the app/models/plan.rb, make sure after that you make that class similar to this below:

class Plan < ApplicationRecord
  # to link the db-backed Plan model with static ActiveHash model of Product
  extend ActiveHash::Associations::ActiveRecordExtensions
  belongs_to_active_hash :product

  delegate :tier, to: :product, prefix: true
end

By running the command below in terminal, you will have subscription table and model automatically generated.

rails g model Subscription account:references plan:references stripe_subscription_id status

Go to the subscription model app/models/subscription.rb and make sure the model looks like this.

class Subscription < ApplicationRecord
  belongs_to :account
  belongs_to :plan

  delegate :product_tier, to: :plan, prefix: false
end

Before we forget, let’s check the account and user models and add the lines below to make it easier to interact with the newly-created subscription model.

class Account < ApplicationRecord
    ## add this
    has_many :subscriptions
    has_one :current_subscription, -> { order(id: :desc) }, class_name: "Subscription"
end

class User < ApplicationRecord
    ## add this
    has_many :subscriptions, through: :accounts, source: :subscription
end

after all this, don’t forget to run rails db:migrate .

Install ruby Stripe and Dotenv gems

We need to install Stripe ruby SDK gem, so shortly we can have with the Stripe API!

Add this to the Gemfile

gem 'stripe'
gem 'dotenv-rails', groups: [:development, :test]

Then run bundle install.

To store sensitive values such as API key for interacting with Stripe API, we can use .env file. Create .env file on the root of your repo and add your Stripe API key that you can retrieve from the developer dashboard.

STRIPE_SECRET_KEY=sk_test_111111111

Now that we have added the API key on the environment variable file, then create initializer file for the Stripe gem config/initializers/stripe.rb to set that key automatically.

Stripe.api_key = ENV['STRIPE_SECRET_KEY']

Seed Stripe product & price

To make it easier for setting and synchronizing up the data both in our database and Stripe’s one, we should have a script for this. You can make it as a rake task, but for the simplicity of running it, we’ll use Rails’ seeds file.

db/seeds.rb

Product.all.each do |product|
  begin
    Stripe::Product.create(
      id: product.stripe_product_id,
      name: product.name
    )
  rescue Stripe::StripeError => error
  end

  # Fetch existing stripe prices of this product
  existing_stripe_prices = Stripe::Price.list(product: product.stripe_product_id)
  existing_stripe_prices.data.select do |price|
    plan = Plan.where(
      interval: price.recurring.interval,
      currency: price.currency.to_s,
      unit_amount: price.unit_amount,
      product_id: product.id
    ).first_or_initialize

    # this will enable us to sync the db records with Stripe
    plan.stripe_price_id = price.id
    plan.save
  end

  product.unit_amounts.each do |interval, data|
    data.each do |currency, amount|
      plan = Plan.where(
        interval: interval, currency: currency.to_s, unit_amount: amount, product_id: product.id
      ).first_or_initialize

      # skip creating the price in Stripe if already synced
      next if plan.stripe_price_id.present?

      stripe_price = Stripe::Price.create(
        product: plan.product.stripe_product_id,
        currency: plan.currency,
        unit_amount: plan.unit_amount,
        nickname: plan.nickname,
        recurring: { interval: plan.interval }
      )
      plan.update(stripe_price_id: stripe_price.id)
    end
  end
end

Whenever we want to seed or sync data to our database, we would just need to run this command:

rails db:seed

Do this if you have not, by the way, to continue follow along the tutorial. By now, we expect you already have records in both the Product and the Plan tables in the database, and also the matching records in Stripe.

Embed Stripe Pricing tables and Checkout

Create pricing table on Stripe Dashboard.

Untitled.png

Untitled (1).png

Add the pricing table on your landing page or pricing page, but in our case we’ll add it to the landing page directly

<script async src="https://js.stripe.com/v3/pricing-table.js"></script>
<stripe-pricing-table pricing-table-id="prctbl_1LZ8xoxoxoxox"
publishable-key="pk_test_90Jbzzzzzzzzzzzzzz">
</stripe-pricing-table>

Obviously we should not commit those static values of the pricing-table-id and publishable-key, so let's utilize the .env variable we created earlier.

STRIPE_PUBLISHABLE_KEY=pk_test_90Jbzzzzzzzzzzzzzz
STRIPE_PRICING_TABLE_ID=prctbl_1LZ8xoxoxoxox
<body>
  <h1>We offer plans that help any business!</h1>
  <!-- Paste your embed code script here. -->
  <script
    async
    src="https://js.stripe.com/v3/pricing-table.js">
  </script>
  <stripe-pricing-table
    pricing-table-id="<%= ENV['STRIPE_PRICING_TABLE_ID'] %>"
    publishable-key="<%= ENV['STRIPE_PUBLISHABLE_KEY'] %>"
  >
  </stripe-pricing-table>
</body>

Add webhooks for listening to the checkout

Before we start writing the code for the webhook, make sure we have installed the Stripe CLI so that we can receive the webhook locally. You can do that easily by following the steps written on this URL. Once you have finished the setup, let's continue.

To be ready for the webhook, let’s add a new endpoint to the routes.rb.

config/routes.rb

    resources :stripe_events, only: :create

Then of course create new controller for working as the webhook.

class StripeEventsController < ActionController::Base
    # The skip below here is very important
    skip_before_filter :verify_authenticity_token

  def create
        payload = request.body.read
      event = nil

      begin
        event = Stripe::Event.construct_from(
          JSON.parse(payload, symbolize_names: true)
        )
            stripe_id = event.data[:object][:id]
      rescue JSON::ParserError => e
        # Invalid payload
        render status: 400
        return
      end

      # Handle the event
      case event.type
        when 'checkout.session.completed'
      else
        raise "Unhandled event type: #{event.type}"
      end

      render status: 200
  end
end

For this skeleton endpoint, there are a number of important things:

  • The StripeEventsController inherit from ActionController::Base instead of ApplicationController, this is to ensure that the controller will not share any existing callbacks listed on ApplicationController. Otherwise you’d have to skip those unrelated, redundant callbacks manually on this controller.
  • The second is the skip_before_filter :verify_authenticity_token line which is important, because otherwise you’d get 422 Unprocessable Entity as response when the webhook endpoint gets hit by Stripe.
when 'checkout.session.completed'
  stripe_checkout = event.data.object
  stripe_customer_id = stripe_checkout.customer
  stripe_subscription_id = stripe_checkout.subscription

  account = Account.where(
    stripe_customer_id: stripe_customer_id,
    email: stripe_checkout.customer_details.email
  ).first_or_create

  stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id)

  # make a loop of this if you expect that the subscription can contain many plans
  stripe_price = stripe_subscription.items.data[0].price
  plan = Plan.find_by(stripe_price_id: stripe_price.id)

  subscription = Subscription.where(stripe_subscription_id: stripe_subscription_id).first_or_initialize
  subscription.assign_attributes(
    plan_id: plan.id,
    account_id: account.id,
    status: stripe_subscription.status
  )
  subscription.save

Since we’re using Stripe Checkout, we can actually achieve what we’ve done above with only one event, which is the checkout.session.completed event, instead of two. The reason you’d want to use the checkout.session.completed event instead would be the simplicity of having to listen to and handle only one event instead of more.

Handle redirect after checkout

So the idea is to redirect the customer to the registration form so they can set the password to their new account, once the payment is complete. Since we’re using Devise on this tutorial, then we’ll use Devise’s existing form page and controller to do that. If you’re using different authentication engine, then you will need to adjust accordingly.

That is to generate the form page, if you haven’t already done so. For the controller, this is the command:

rails generate devise:controllers users

config/routes.rb

Rails.application.routes.draw do
    ###
    devise_for :users, controllers: {
    registrations: 'users/registrations'
  }
  get '/checkouts/:id/complete', to: redirect('/users/sign_up?stripe_checkout_id=%{id}')
    ###
end

Notice that we specify the registrations of devise to be handled by the controller we generated from the rails generate devise:controllers users

Go back to the pricing table on the Stripe dashboard and edit it. Once you click continue, you can see the field for the redirect url. Fill it to your local development and add the route `http://lvh.me:3000/checkouts/{CHECKOUT_SESSION_ID}/complete. For this tutorial, we are assuming that you will be usinglvh.me` for your local development.

Now that we’ll be redirecting them to the Devise registration controller, we will need to adjust the registration controller just a little bit.

class Users::RegistrationsController < Devise::RegistrationsController
    before_action :memoize_checkout, only: :new
    after_action :relate_account, only: :create

    # Add these lines on the bottom of the controller
    private

    # This method is for prefilling the email field for the registration form
    # with the email the customer used to checkout on Stripe Checkout earlier
    def build_resource(hash = {})
    self.resource = resource_class.new_with_session(
      hash.merge(
        email: session['stripe_checkout']['customer_details']['email']
      ),
      session
    )
  end

    # This method is for storing the checkout session object to the session,
    # once the customer gets redirected from Stripe Checkout
    def memoize_checkout
    return unless params[:stripe_checkout_id]
    session[:stripe_checkout] ||= Stripe::Checkout::Session.retrieve params[:stripe_checkout_id].as_json
  end

    # This is for hooking up the newly created user account after registration is successful
    # with the account object, to link user and the paid subscription
    def relate_account
        # alternative 1: find account by email
        # account = Account.find_by_email signup_params[:email]
        # alternative 2: find account by retrieving Stripe customer id from Stripe
        account = Account.find_by(stripe_customer_id: session['stripe_checkout']['customer'])
        # Associate the matching Stripe customer object, our Account object, and the newly-registered User object.
        account.account_users << resource
        account.save
    end
end

Before we start testing, we might want to redirect the user to different page after registration is successful, we can do this by:

# config/routes.rb
Rails.application.routes.draw do
    authenticate :user do
    get "dashboard" => "home#dashboard", as: :user_root
  end
end

# app/controllers/home_controller.rb
class HomeController < ApplicationController
    # add this endpoint
    def dashboard
  end
end

# app/views/home/dashboard.html.erb
<h2>Dashboard</h2>
<p>Thank you for registering!</p>

One last thing

By this point, you should have a complete end-to-end integration from the checkout and the registration, and the eventual authentication. Your customer would have their payments related correctly to their user record via the account object. So the last step below is entirely optional since it is a nice-to-have.

To make it easier to reference the current_user ’s account and subscription, we will create some helpers. First, we go back to the registration controller.

    def relate_account
        account = Account.find_by(stripe_customer_id: session[:stripe_customer_id])
        account.account_users << resource
        # we just change the last line of the method
        if account.save
            session[:current_account_id] = account.id
            session.delete(:stripe_checkout)
        end
    end

The current_account_id session key will be used to determine the account of the currently logged in user. So this will be just as useful as current_user to scope resources/records. For example, only

class ApplicationController < ActionController::Base

    def current_account
        @current_account ||= current_user.accounts.find(session[:current_account_id])
    end
    helper_method :current_account

    def current_subscription
    @current_subscription ||= current_account.current_subscription
  end
    helper_method :current_subscription
end

With this, for example if you want to check if the current_user is allowed to access certain pages/dashboards based on their current subscription:

class ArticlesController < ApplicationController
    before_action :allow_only_premium_users, only: :restricted_page

    def index
    end

    def restricted_page
  end

    private

    def allow_only_premium_users
        redirect_to '/404' if current_subscription.product_tier != 'premium'
    end
end

Now, in most apps, there are two places where we authenticate the user, the first is the registration and the other one is the login (session create). So we then need to ensure the current_account_id session key gets set everytime customer re-login back to the app.

class Users::SessionsController < Devise::RegistrationsController
    after_action :set_current_account_id, only: [:create]

    private

    def set_current_account_id
        # find the last used account by relying on the timestamp
        # this way, for example if you implement multi-accounts for a given user,
        # you just need to update the timestamp everytime a user swtiches the account
        session[:current_account_id] = current_user.user_accounts.order(updated_at: :desc).first.id
    end
end

If you want to be super clean, you can refactor this a bit better:

class UserAccount < ApplicationRecord
    ###
    scope :order_by_recently_accessed, -> { order(updated_at: :desc) }
    ###
end
class User
    has_one :recent_account_user, -> { order_by_recently_accessed}, class_name: 'AccountUser'
end

then the previous after_action becomes like this:

    def set_current_account_id
        # find the last used account by relying on the timestamp
        # this way, for example if you implement multi-accounts for a given user,
        # you just need to update the timestamp everytime a user swtiches the account
        session[:current_account_id] = current_user.recent_account_user.id
    end

That’s it, now we’ve got a super integrated registration flow with checkout easily enabled using Stripe Pricing Table + Stripe Checkout combo! Remember that you can always access the code here.

 
Share this