Easy SaaS subscription flow with Stripe Checkout in Rails
Galih Muhammad
Posted on September 25, 2022
What we are going to build is a Stripe checkout integration for subscription flow of a SaaS app like this:
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, were assuming well 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, well 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 wont be many changes we expect to make for this kind of records, lets 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, lets 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 lets 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
Modeling the subscription and link it to the user
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, lets 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, dont 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 Stripes one, we should have a script for this. You can make it as a rake task, but for the simplicity of running it, well 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.
Add the pricing table on your landing page or pricing page, but in our case well 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, lets 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 fromActionController::Base
instead ofApplicationController
, this is to ensure that the controller will not share any existing callbacks listed onApplicationController
. Otherwise youd 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 youd get422 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 were using Stripe Checkout, we can actually achieve what weve done above with only one event, which is the checkout.session.completed
event, instead of two. The reason youd 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 were using Devise on this tutorial, then well use Devises existing form page and controller to do that. If youre using different authentication engine, then you will need to adjust accordingly.
That is to generate the form page, if you havent 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 using
lvh.me` for your local development.
Now that well 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
Thats it, now weve 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.
Posted on September 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.