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
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
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
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
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]
You know can now already redirect your users to your Stripe's checkout page:
<%= button_to "Subscribe", billings_path, method: :post, data: {turbo: false} %>
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
Then extend the previously added route:
resource :billings, only: %w[create edit]
With this button, you can redirect your users to their billing portal:
<%= button_to "Manage your subscription", edit_billings_path, data: {turbo: false} %>
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"
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
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
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
And then the Webhook model:
class Webhook < ApplicationRecord
enum :status, %w[pending completed].index_by(&:itself), default: "pending"
end
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
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
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)