Skip to content

Instantly share code, notes, and snippets.

@galliani
Last active June 8, 2024 08:09
Show Gist options
  • Save galliani/0fdb5ffa17cf209bd1694bdec25ca693 to your computer and use it in GitHub Desktop.
Save galliani/0fdb5ffa17cf209bd1694bdec25ca693 to your computer and use it in GitHub Desktop.
SaaS subscription checkout with Stripe in Rails, guide here: https://rubyist.info/saas-subscription-flow-with-stripe-checkout-in-rails
# SaaS checkout flow in Rails with stripe webhook.
class Account < ApplicationRecord
has_many :account_users
has_many :users, through: :account_users, source: :user
has_many :subscriptions
has_one :current_subscription, -> { order(id: :desc) }, class_name: "Subscription"
end
class AccountUser < ApplicationRecord
belongs_to :account
belongs_to :user
end
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_page_title
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
def allow_only_premium_users
redirect_to '/404' if current_subscription.product_tier != 'premium'
end
private
def set_page_title
@page_title = t(".page_title", default: "").presence || t("#{controller_name}.page_title", default: "").presence || controller_name.titleize
end
end
gem 'active_hash'
gem 'stripe'
gem 'dotenv-rails', groups: [:development, :test]
gem 'devise'
class HomeController < ApplicationController
skip_before_action :authenticate_user!, only: :index
before_action :allow_only_premium_users, only: :restricted_page
def index
end
def dashboard
end
def restricted_page
end
end
<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>
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
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
# frozen_string_literal: true
class Users::RegistrationsController < Devise::RegistrationsController
before_action :memoize_checkout, only: :new
after_action :relate_account, only: :create
# before_action :configure_sign_up_params, only: [:create]
# before_action :configure_account_update_params, only: [:update]
# GET /resource/sign_up
# def new
# super
# end
# POST /resource
# def create
# super
# end
# GET /resource/edit
# def edit
# super
# end
# PUT /resource
# def update
# super
# end
# DELETE /resource
# def destroy
# super
# end
# GET /resource/cancel
# Forces the session data which is usually expired after sign
# in to be expired now. This is useful if the user wants to
# cancel oauth signing in/up in the middle of the process,
# removing all OAuth session data.
# def cancel
# super
# end
# protected
# If you have extra params to permit, append them to the sanitizer.
# def configure_sign_up_params
# devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute])
# end
# If you have extra params to permit, append them to the sanitizer.
# def configure_account_update_params
# devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
# end
# The path used after sign up.
# def after_sign_up_path_for(resource)
# super(resource)
# end
# The path used after sign up for inactive accounts.
# def after_inactive_sign_up_path_for(resource)
# super(resource)
# end
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
return unless session[:stripe_checkout]
# 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.build(user: resource)
if account.save
session[:current_account_id] = account.id
session.delete(:stripe_checkout)
end
end
end
Rails.application.routes.draw do
resources :stripe_events, only: :create
devise_for :users, controllers: {
registrations: 'users/registrations'
}
get '/checkouts/:id/complete', to: redirect('/users/sign_up?stripe_checkout_id=%{id}')
get "home/index"
root "home#index"
authenticate :user do
get "dashboard" => "home#dashboard", as: :user_root
get "restricted_page" => "home#restricted_page"
end
end
rails g devise:install
rails g model Account email stripe_customer_id
rails g model AccountUser user:references account:references role
rails g model Plan product_id interval currency nickname unit_amount stripe_price_id
rails g model Subscription account:references plan:references stripe_subscription_id status
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
Stripe.api_key = ENV['STRIPE_SECRET_KEY']
class StripeEventsController < ActionController::Base
skip_before_action :verify_authenticity_token
def create
payload = request.body.read
event = nil
begin
event = Stripe::Event.construct_from(
JSON.parse(payload, symbolize_names: true)
)
rescue JSON::ParserError => e
# Invalid payload
render status: 400
return
end
# Handle the event
case event.type
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
end
render json: {}, status: 200
end
end
class Subscription < ApplicationRecord
belongs_to :account
belongs_to :plan
delegate :product_tier, to: :plan, prefix: false
end
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :lockable, :trackable
validates_presence_of :email, :password
has_many :account_users
has_many :accounts, through: :account_users, source: :account
has_many :subscriptions, through: :accounts, source: :subscription
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment