April 23, 2018
•Last updated November 5, 2023
Let's Build: With Ruby on Rails - Book Library App with Stripe Subscription Payments
Welcome to my eleventh Let's Build series featuring Ruby on Rails. This installment, once again, focuses on accepting payments using Ruby on Rails as a web application framework. We partner Ruby on Rails with Stripe to create a subscription-based SaaS model for a book library application.
What's new in this build?
- Rails 5.2 makes an appearance
- We use ActiveStorage which comes new with Rails 5.2. No longer do we require third-party gems like PaperClip or Carrierwave.
- New encrypted secrets which make managing any environment variables or keys way easier. You can commit these to version control without fear thanks to the encrypted quality
- Stripe Billing - Stripe recently released a new model around their subscription products. Within Stripe Billing you can define overlying products that have associated plans. It's a nicer way to bundle whatever it is you sell but still be able to sell things in different manners i.e. Flat rates, Multiple Plans, Per seat, Usage-based, and Flat rate + overage.
I used a new Ruby on Rails application template (based on Bulma CSS in this series of which you can download here
The Book Library application
The application is a simple book application that allows a user to add and remove books from their library. These books can be downloaded for reading once a user is subscribed. Accessing a library is only possible if a user is subscribed. This app differs from previous apps where a user must pay before making an account.
A user is presented with buttons for adding a book to their library. As you can guess, to do this action a user first needs an account. Upon clicking the "add to library" button a public-facing user (someone with no account) is redirected to a signup page of which we implement using the Devise gem.
A nice callback function from Devise allows us to redirect a user upon successfully signing up. In this case, we redirect a user to a pricing page that features three plans to choose from. Each plan has its own parameters associated with it. Upon clicking the "subscribe" button on any of the tiers, the user is redirected to a payment page carrying over the necessary parameters to hook into Stripe with.
What seems like a simple app carries some logic and weight to cover all our tracks. The application is far from complete but you can, however, extend this to be much more however you like.
I ran out of time but one obvious area to extend this app is to save what plan a user has subscribed to. This can be done at the time they create a subscription. From there you can query different places in your app to show and hide specific features based on their plan type.
Part 1
Part 2
Part 3
Part 4
Part 5
Part 6
Models and Relationships
Our app features just three models. At all times we access books and users through a library model. This ends up being a many-to-many
association.
User
bash
email
name
stripe_id
stripe_subscription_id
card_last4
card_exp_month
card_exp_year
card_type
admin
subscribed
Book
bash
title
description
author
user_id
Library
bash
book_id
user_id
The relationships end up as follows:
#app/models/book.rb
class Book < ApplicationRecord
has_one_attached :thumbnail
belongs_to :user
has_many :libraries
has_many :added_books, through: :libraries, source: :user
end
#app/models/library
class Library < ApplicationRecord
belongs_to :book
belongs_to :user
end
#app/models/user
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
has_many :charges
has_many :books, dependent: :destroy
has_many :libraries
has_many :library_additions, through: :libraries, source: :book
def subscribed?
stripe_subscription_id?
end
end
Controllers
Our controllers are where much of the magic happens when submitting a successful payment as well as a subscribing a new user.
The easy controller to start with is our book controller. This mimics many of our previous series where a user needed to be associated with another model. Be sure your Book
model has a user_id
field.
Books Controller
class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy, :library]
before_action :authenticate_user!, except: [:index, :show]
# GET /books
# GET /books.json
def index
@books = Book.all
end
# GET /books/1
# GET /books/1.json
def show
end
# GET /books/new
def new
@book = current_user.books.build
end
# GET /books/1/edit
def edit
end
# POST /books
# POST /books.json
def create
@book = current_user.books.build(book_params)
respond_to do |format|
if @book.save
format.html { redirect_to @book, notice: 'Book was successfully created.' }
format.json { render :show, status: :created, location: @book }
else
format.html { render :new }
format.json { render json: @book.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /books/1
# PATCH/PUT /books/1.json
def update
respond_to do |format|
if @book.update(book_params)
format.html { redirect_to @book, notice: 'Book was successfully updated.' }
format.json { render :show, status: :ok, location: @book }
else
format.html { render :edit }
format.json { render json: @book.errors, status: :unprocessable_entity }
end
end
end
# DELETE /books/1
# DELETE /books/1.json
def destroy
@book.destroy
respond_to do |format|
format.html { redirect_to books_url, notice: 'Book was successfully destroyed.' }
format.json { head :no_content }
end
end
# Add and remove books to library
# for current_user
def library
type = params[:type]
if type == "add"
current_user.library_additions << @book
redirect_to library_index_path, notice: "#{@book.title} was added to your library"
elsif type == "remove"
current_user.library_additions.delete(@book)
redirect_to root_path, notice: "#{@book.title} was removed from your library"
else
# Type missing, nothing happens
redirect_to book_path(@book), notice: "Looks like nothing happened. Try once more!"
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_book
@book = Book.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the whitelist through.
def book_params
params.require(:book).permit(:title, :description, :author, :thumbnail, :user_id)
end
end
In this controller, you will see one new action in place. This action allows us to actually add a book to a given user's library. We take the parameter passed through from the view and determine if we are adding or removing the book. While we are at it we display notice messages to improve the UX.
# Add and remove books to the library
# for current_user
def library
type = params[:type]
if type == "add"
current_user.library_additions << @book
redirect_to library_index_path, notice: "#{@book.title} was added to your library"
elsif type == "remove"
current_user.library_additions.delete(@book)
redirect_to root_path, notice: "#{@book.title} was removed from your library"
else
# Type missing, nothing happens
redirect_to book_path(@book), notice: "Looks like nothing happened. Try once more!"
end
end
This action does require some changes to our routes file. The final route file looks like this:
#config/routes.rb
require 'sidekiq/web'
Rails.application.routes.draw do
resources :library, only:[:index]
mount Sidekiq::Web => '/sidekiq'
resources :books do
member do
put "add", to: "books#library"
put "remove", to: "books#library"
end
end
devise_for :users, controllers: { registrations: "registrations" }
root to: 'books#index'
resources :pricing, only:[:index]
resources :subscriptions
end
Notice who within resources :books
we declare a block and add a member
do block.
The corresponding markup to make this all work looks like this in our _book.html.erb
partial.
<div class="content">
<% if subscribed? %>
<% if user_added_to_library?(current_user, book) %>
<%= link_to 'Remove from library', add_book_path(book, type: "remove"), method: :put, class: "button is-danger is-fullwidth" %>
<% if controller.controller_name == "library" %>
<%= link_to 'Download', '#', class:"button is-success is-fullwidth mt1" %>
<% end %>
<% else %>
<%= link_to 'Add to library', add_book_path(book, type: "add"), method: :put, class: "button is-link is-fullwidth" %>
<% end %>
<% else %>
<%= link_to 'Add to library', new_user_registration_path, class: "button is-link is-fullwidth" %>
<% end %>
</div>
We are doing multiple things here but the main focus are the link_to
methods. These pass the add_book_path()
along with the book
, and the type
of "add" or remove. You can pass any param with a link like this but since we want our route to match the naming convention this is why they do.
Subscriptions Controller
class SubscriptionsController < ApplicationController
layout "subscribe"
before_action :authenticate_user!, except: [:new, :create]
def new
if user_signed_in? && current_user.subscribed?
redirect_to root_path, notice: "You are already a subscriber!"
end
end
def create
Stripe.api_key = Rails.application.credentials.stripe_api_key
plan_id = params[:plan_id]
plan = Stripe::Plan.retrieve(plan_id)
token = params[:stripeToken]
product = Stripe::Product.retrieve(Rails.application.credentials.book_library)
customer = if current_user.stripe_id?
Stripe::Customer.retrieve(current_user.stripe_id)
else
Stripe::Customer.create(email: current_user.email, source: token)
end
subscription = customer.subscriptions.create(plan: plan.id)
options = {
stripe_id: customer.id,
stripe_subscription_id: subscription.id,
subscribed: true,
}
options.merge!(
card_last4: params[:user][:card_last4],
card_exp_month: params[:user][:card_exp_month],
card_exp_year: params[:user][:card_exp_year],
card_type: params[:user][:card_type]
) if params[:user][:card_last4]
current_user.update(options)
redirect_to root_path, notice: "🎉 Your subscription was set up successfully!"
end
def destroy
customer = Stripe::Customer.retrieve(current_user.stripe_id)
customer.subscriptions.retrieve(current_user.stripe_subscription_id).delete
current_user.update(stripe_subscription_id: nil)
redirect_to root_path, notice: "Your subscription has been cancelled."
end
end
Our subscriptions controller gets all the logic we need to be associated with the Stripe gem. Combined with some markup and Javascript from the Stripe Elements library we take in parameters from the view.
plan_id = params[:plan_id] # passed in through the view
plan = Stripe::Plan.retrieve(plan_id) # retrives the plans on stripe.com of which we already made
token = params[:stripeToken] # necessary to submit payment to stripe
We can then either retrieve an exsting customer or create a new one with these lines:
customer = if current_user.stripe_id?
Stripe::Customer.retrieve(current_user.stripe_id)
else
Stripe::Customer.create(email: current_user.email, source: token)
end
Finally to create a new subscription we call this line:
subscription = customer.subscriptions.create(plan: plan.id)
While we are at it you can update a few things related to your user:
options = {
stripe_id: customer.id, # saves customer id for later
stripe_subscription_id: subscription.id, # allows us to modify subscription where applicable
subscribed: true, # dictates if a user is subscribed or not
}
options.merge!(
card_last4: params[:user][:card_last4],
card_exp_month: params[:user][:card_exp_month],
card_exp_year: params[:user][:card_exp_year],
card_type: params[:user][:card_type]
) if params[:user][:card_last4]
current_user.update(options) # updates/adds all options to user account from above
redirect_to root_path, notice: "🎉 Your subscription was set up successfully!"
The view repsonisble for a lot of this data looks like this:
<!-- app/views/subscriptions/new -->
<section class="section">
<div class="columns is-centered">
<div class="column is-6 border pa5">
<h1 class="title is-3">Subscribe</h1>
<hr />
<p>Chosen plan: <strong><%= params[:plan] %></strong></p>
<hr />
<%= form_tag subscriptions_path, id: "payment-form" do |form| %>
<div class="field">
<label class="label" for="card-element">
Enter credit or debit card
</label>
<div id="card-element">
<!-- A Stripe Element will be inserted here. -->
</div>
<!-- Used to display Element errors. -->
<div id="card-errors" role="alert"></div>
<%= hidden_field_tag :plan_id, params[:plan_id] %>
<button class="button is-fullwidth is-link mt4">Submit</button>
</div>
<% end %>
</div>
</div>
</section>
Ultimately, we are using Stripe Elements to render a payment form. The form input gets generated using JavaScript along with error handling. I find this to be the easiest method as opposed to rolling your own form. You might roll your own if you need to handle multiple merchants like PayPal.
Before a user lands on this page they have already come from the pricing page where they chose a plan. Each plan has an associated parameter passed through to this view which explains the params[:plan]
as well as the hidden field tag:
<%= hidden_field_tag :plan_id, params[:plan_id] %>
We need a way to know which plan to charge the user for. This is a quick way to do just that. This id gets fed through from the pricing page which I'll talk about next but before I do let's look at the JavaScript required to make this all happen:
document.addEventListener("turbolinks:load", function() {
const publishableKey = document.querySelector("meta[name='stripe-key']").content;
const stripe = Stripe(publishableKey);
const elements = stripe.elements({
fonts: [{
cssSrc: "https://rsms.me/inter/inter-ui.css"
}],
locale: 'auto'
});
// Custom styling can be passed to options when creating an Element.
const style = {
base: {
color: "#32325D",
fontWeight: 500,
fontFamily: "Inter UI, Open Sans, Segoe UI, sans-serif",
fontSize: "16px",
fontSmoothing: "antialiased",
"::placeholder": {
color: "#CFD7DF"
}
},
invalid: {
color: "#E25950"
}
};
// Create an instance of the card Element.
const card = elements.create('card', { style });
// Add an instance of the card Element into the `card-element` <div>.
card.mount('#card-element');
card.addEventListener('change', ({ error }) => {
const displayError = document.getElementById('card-errors');
if (error) {
displayError.textContent = error.message;
} else {
displayError.textContent = '';
}
});
// Create a token or display an error when the form is submitted.
const form = document.getElementById('payment-form');
form.addEventListener('submit', async(event) => {
event.preventDefault();
const { token, error } = await stripe.createToken(card);
if (error) {
// Inform the customer that there was an error.
const errorElement = document.getElementById('card-errors');
errorElement.textContent = error.message;
} else {
// Send the token to your server.
stripeTokenHandler(token);
}
});
const stripeTokenHandler = (token) => {
// Insert the token ID into the form so it gets submitted to the server
const form = document.getElementById('payment-form');
const hiddenInput = document.createElement('input');
hiddenInput.setAttribute('type', 'hidden');
hiddenInput.setAttribute('name', 'stripeToken');
hiddenInput.setAttribute('value', token.id);
form.appendChild(hiddenInput);
["type", "last4", "exp_month", "exp_year"].forEach(function(field) {
addCardField(form, token, field);
});
// Submit the form
form.submit();
}
function addCardField(form, token, field) {
let hiddenInput = document.createElement('input');
hiddenInput.setAttribute('type', 'hidden');
hiddenInput.setAttribute('name', "user[card_" + field + "]");
hiddenInput.setAttribute('value', token.card[field]);
form.appendChild(hiddenInput);
}
});
If you're following along step-by-step you may have a subscriptions.coffee
file inside your app/assets/javascripts
directory. If so, rename it to subscriptions.js
and type in the code above.
We created a new custom layout for the payment flow. This is denoted in our subscriptions_controller.rb
at the very top using a simple line :
class SubscriptionsController < ApplicationController
layout "subscribe"
...
end
This allows us to create a new file called subscribe.html.erb
within app/views/layouts/
.
You'll need to add a meta tag to your subscribe.html.erb
layout file to get this to work. Note how I added the stripe js library as well https://js.stripe.com/v3/
. That code looks like this:
<head>
<%= javascript_include_tag 'application', 'https://js.stripe.com/v3/', 'data-turbolinks-track': 'reload' %>
<%= tag :meta, name: "stripe-key", content: Rails.application.credentials.stripe_publishable_key %>
</head>
As part of Rails 5.2 we are accessing our new credentials in a different way than the previous series. Be sure to watch the videos on how to edit/update/store these.
With that outputting your test
stipe publishable key you are now ready to implement the javascript for Stripe to do it's magic.
The Pricing Page
Getting all the plans and parameters we need requires some user input. The pricing page is responsible for this.
<section class="section">
<div class="has-text-centered box">
<h1 class="title">Pricing</h1>
<h2 class="subtitle">Choose the best plan that matches your reading consistency and budget.</h2>
<div class="pricing-table">
<div class="pricing-plan is-warning">
<div class="plan-header">Starter</div>
<div class="plan-price"><span class="plan-price-amount"><span class="plan-price-currency">$</span>5</span>/month</div>
<div class="plan-items">
<div class="plan-item">5 Downloads</div>
</div>
<div class="plan-footer">
<% if user_signed_in? %>
<%= link_to 'Subscribe', new_subscription_path(plan: "starter", plan_id: Rails.application.credentials.starter), class:"button is-fullwidth" %>
<% else %>
<%= link_to 'Subscribe', new_user_registration_path, class:"button is-fullwidth" %>
<% end %>
</div>
</div>
<div class="pricing-plan is-link is-active">
<div class="plan-header">Book Worm</div>
<div class="plan-price"><span class="plan-price-amount"><span class="plan-price-currency">$</span>10</span>/month</div>
<div class="plan-items">
<div class="plan-item">10 Downloads</div>
</div>
<div class="plan-footer">
<% if user_signed_in? %>
<%= link_to 'Subscribe', new_subscription_path(plan: "book_worm", plan_id: Rails.application.credentials.book_work), class:"button is-fullwidth" %>
<% else %>
<%= link_to 'Subscribe', new_user_registration_path, class:"button is-fullwidth" %>
<% end %>
</div>
</div>
<div class="pricing-plan is-danger">
<div class="plan-header">Scholar</div>
<div class="plan-price"><span class="plan-price-amount"><span class="plan-price-currency">$</span>30</span>/month</div>
<div class="plan-items">
<div class="plan-item">30 Downloads</div>
</div>
<div class="plan-footer">
<% if user_signed_in? %>
<%= link_to 'Subscribe', new_subscription_path(plan: "scholar", plan_id: Rails.application.credentials.scholar), class:"button is-fullwidth" %>
<% else %>
<%= link_to 'Subscribe', new_user_registration_path, class:"button is-fullwidth" %>
<% end %>
</div>
</div>
</div>
</div>
</section>
Out of the box, Bulma doesn't support this markup. You can extend bulma with this library to add the extra CSS to do so.
Apart from that you can see that each subscribe button features it's own set of parameters. We store the plan_id
params as secret credentials. You can grab these from plans you create on stripe.com in your dashboard. We also pass the plan name itself just to aid the UX on the next screen where it comes time for a user to know what they are purchasing. As I said before this is a good spot to save the plan type to your user model so you can dictate what features you want available for each plan type.
Rounding out
Overall this app seems fairly simple. We are using some exciting new features of Rails 5.2 of which I talk more in depth about in the videos. I invite you to follow along and as always feel free to ask questions. The comments below or the comments on YouTube are a great way to gain feedback. The same is true for the source code on Github.
If you made it this far, I can't thank you enough. This experience has been a massive learning one for me. I chose to learn in public not only to share what I learned but gain feedback on if I'm doing something wrong or not up to spec. Feel free to critique and if you're new here be sure to check out all of my previous series!
- Let’s Build: With Ruby on Rails – Introduction
- Let’s Build: With Ruby on Rails – Installation
- Let’s Build: With Ruby on Rails – Blog with Comments
- Let’s Build: With Ruby on Rails – A Twitter Clone
- Let’s Build: With Ruby on Rails – A Dribbble Clone
- Let’s Build: With Ruby on Rails – Project Management App
- Let’s Build: With Ruby on Rails – Discussion Forum
- Let’s Build: With Ruby on Rails – Deploying an App to Heroku
- Let’s Build: With Ruby on Rails – eCommerce Music Shop
- Let's Build: With Ruby on Rails - Job Board with Payments
Categories
Collection
Part of the Let's Build: With Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.