DEV Community

Cover image for Let's Build: With Ruby on Rails - Book Library App with Stripe Subscription Payments
Andy Leverenz
Andy Leverenz

Posted on

Let's Build: With Ruby on Rails - Book Library App with Stripe Subscription Payments

Welcome to my Let's Build series featuring Ruby on Rails. This installment 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 fictitious 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 which 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

GitHub logo justalever / book_library

A SaaS based demo app revolving around book downloads and stripe subscriptions

Let's Build

Let's Build: With Ruby on Rails - A 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 which have associated plans. It's a nicer way to bundle…

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 than 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 which 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


email
name
stripe_id
stripe_subscription_id
card_last4
card_exp_month
card_exp_year
card_type
admin
subscribed 

Book


title
description
author
user_id

Library


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!

Related series

  1. Let’s Build: With Ruby on Rails – Introduction
  2. Let’s Build: With Ruby on Rails – Installation
  3. Let’s Build: With Ruby on Rails – Blog with Comments
  4. Let’s Build: With Ruby on Rails – A Twitter Clone
  5. Let’s Build: With Ruby on Rails – A Dribbble Clone

Shameless plug time

I have a new course called Hello Rails. Hello Rails is modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. Sign up to get notified today!

Follow @hello_rails and myself @justalever on Twitter.

Top comments (1)

Collapse
 
dbelyaeff profile image
Dmitriy Belyaev

Missing syntax highlighting in the post.