Last active
June 8, 2024 08:09
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# SaaS checkout flow in Rails with stripe webhook. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class AccountUser < ApplicationRecord | |
belongs_to :account | |
belongs_to :user | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
gem 'active_hash' | |
gem 'stripe' | |
gem 'dotenv-rails', groups: [:development, :test] | |
gem 'devise' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Stripe.api_key = ENV['STRIPE_SECRET_KEY'] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Subscription < ApplicationRecord | |
belongs_to :account | |
belongs_to :plan | |
delegate :product_tier, to: :plan, prefix: false | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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