Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

August 24, 2022

Last updated November 13, 2023

Let's Build an event scheduling app with Ruby on Rails 7

I recently posted a poll on YouTube about what "Let's Build" series to revamp, and the answer was a unanimous event scheduling type of Rails application.

In 2018, I published Let's Build: With Ruby on Rails - Event Scheduling App with Payments, which was my first attempt at such an app. A lot has changed since then with Rails and also Stripe.

In this tutorial, we build an application inspired by Calendly, an excellent tool for scheduling events or meetings with pre-established availability. Calendly seems simple on the surface, but once you start working with anything related to dates and time in programming, it starts to get complex fast.

This tutorial is in no way complete. The app's final version is downright scrappy, to be honest, but I wanted to take something out in the wild already and dumb it down into something you could rebuild with Rails.

Goals for the app

The general idea is to mimic the primitive features of Calendly. An end user can visit a unique direct link to book events with someone else. These events can be free to book or require payment. Those that book events shouldn't be necessary to create an account. Those that make the events and availability slots do need an account.

The video version

Part 1 - Introduction

Part 2 - Modeling the app

Part 3 - Setting up the UI

Part 4 - Configuring controllers

Part 5 - Seeding data and payment setup

Part 6 - Payments with Stripe Payment Element

Kicking off the app

If you've followed my tutorials previously, you know I tend to start with a small template when creating a new rails application. I'll do the same in this tutorial, but free to start from scratch if that suits you.

Here's my template: Kickoff Tailwind. The readme should tell you more about it and how to use it. I download it to my machine rather than refer to it remotely when creating a new app.

rails new bookme -m kickoff_tailwind/template.rb -j esbuild -d postgresql

The command above creates a new Ruby on Rails 7 application and passes a few options. The app will make use of my template, which is denoted by the -m' flag.
We use
-jto tell Rails to installesbuildinstead of the default import maps logic for JavaScript-related code. Finally, the-d' flag tells Rails to use PostgreSQL for the database.

With any luck, this should install a new Rails app and configure a few dependencies that my template copies over.

Routing

Let's set up the initial routing with those who will create the events (or what I'll call BookingTypes) in mind.

# config/routes.rb
require 'sidekiq/web'

Rails.application.routes.draw do
    authenticate :user, lambda { |u| u.admin? } do
      mount Sidekiq::Web => '/sidekiq'
    end

  devise_for :users

  # be sure to add before default `root`
  authenticated :user do
    root to: "home#dashboard", as: :authenticated_root
  end

  root to: 'home#index'
end

Here we used an excellent built-in routing method by the Devise gem. The authenticated :user block will check if a user is indeed signed in and, if so, redirect to the new root path. So this means we can have a root path for public users who are not signed in and a different root path for those who are signed in.

The line root to: "home#dashboard", as: :authenticated_root tells Rails that we will have a home_controller.rb file inside the app/controllers folder. Rails expects to see an action called dashboard inside that file.

Let's add that now. If you use my template, the file should already exist, but we need to add the action.

# app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end

  def dashboard
  end
end

Next, we need to add a view file called dashboard.html.erb to app/views/home.

Boot your server by running bin/dev or rails server. You can add some dummy text to the dashboard.html.erb file. Once that's complete, create your first account at localhost:3000/users/sign_up.

After signing in, you should go to that page and see the added text.

Modeling the app

Our app will feature three core models.

  • User - How we keep a record of those that create event types.
  • Booking - A record of each event as booked by public users
  • BookingType - A record of each type of event an end user can book.

Thanks to the Kickoff Tailwind template, the' User' model is already present.

Creating bookings

Let's scaffold our Booking model. This will help set the stage for the rest of the logic we'll need to add.

rails g scaffold Booking status:integer first_name:string last_name:string email:string start_at:datetime end_at:datetime customer_paid:boolean

We'll use a status column to change the status of bookings. Because a booking is "booked" by a public-facing user, we'll need to capture some contact information, including their name and email. Finally, the booking dates are crucial, so we'll capture the start_at and end_at dates.

Go ahead and run that scaffold generator. Head to the db/migrate folder and locate the latest migration file added. The file will have a unique name for the time you created it.

# db/migrate/XXXXXXXX_create_bookings.rb
class CreateBookings < ActiveRecord::Migration[7.0]
  def change
    create_table :bookings do |t|
      t.integer :status, default: 0
      t.datetime :start_at
      t.datetime :end_at
      t.string :first_name
      t.string :last_name
      t.string :email
      t.boolean :customer_paid, default: false

      t.timestamps
    end
  end
end

Before migrating the database changes, we need to add some defaults. I added one to the :status column and the :customer_paid column. Notice how :status is a type of integer and the customer_paid is the type of boolean. Because of this, the default values will be different. One could argue that they could be the same, though! (For a boolean, it could just as easily be 0 or false as they mean the same thing.)

Create booking types

rails g scaffold BookingType name:string location:string description:rich_text color:string duration:integer payment_required:boolean price:integer user:references

The BookingType model will be how events get categorized. This model will also determine if a booking requires payment to create. A booking type will need to be authored by a user, so we added the user:references shorthand that helps map the logic necessary. A BookingType will belong to a given user, and a user can create many of those.

Additional migrations

A couple more columns are required to make the app function as advertised. The first two will add a reference of BookingType to bookings, which means any new booking will be associated with a BookingType by default.

The second migration extends our pre-existing User model to include a new booking_link field. This field will be unique, meaning there can only be one of its kind in the database. We'll use this field to create a dynamic URL for users to access. Think of this field as the source of truth for your shareable booking link.

rails g migration add_references_to_bookings booking_type_id:integer
rails g migration add_booking_link_to_users booking_link:string:uniq

Migrate it all into the database

We can finally migrate the database with those new models and migrations.

rails db:migrate

Extending the Devise forms

With our new booking_link field in place, we still have work to do so a new user can add it when signing up. I'm using Devise (installed and configured with the kickoff Tailwind theme), which allows you to extend the gem to include additional form fields.

We must extend it to include the booking_link field inside the application_controller.rb file.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :booking_link])
      devise_parameter_sanitizer.permit(:account_update, keys: [:name, :booking_link])
    end
end

With that done, we can update the views to include the new field. Below are the views found in app/views/devise/registrations/

<!-- app/views/devise/registrations/new.html.erb -->
<%= render layout: "auth_layout", locals: { title: "Sign up" } do %> <%=
form_for(resource, as: resource_name, url: registration_path(resource_name),
data: { turbo: false }) do |f| %> <%= render "devise/shared/error_messages",
resource: resource %>

<div class="mb-6">
  <%= f.label :name, class: label_class %> <%= f.text_field :name, class:
  input_class %>
</div>

<div class="mb-6">
  <%= f.label :email, class: label_class %> <%= f.email_field :email,
  autocomplete: "email", class: input_class %>
</div>

<%# Add this field %>
<div class="mb-6">
  <%= f.label :booking_link, class: label_class %> <%= f.text_field
  :booking_link, class: input_class %>
</div>

<div class="mb-6">
  <div class="flex">
    <%= f.label :password, class: label_class %> <% if @minimum_password_length
    %>
    <span class="pl-1 text-xs text-gray-600 mt-1"
      ><em>(<%= @minimum_password_length %> characters minimum)</em></span
    >
    <% end %>
  </div>
  <%= f.password_field :password, autocomplete: "new-password", class:
  input_class %>
</div>

<div class="mb-6">
  <%= f.label :password_confirmation, class: label_class %> <%= f.password_field
  :password_confirmation, autocomplete: "new-password", class: input_class %>
</div>

<div class="mb-6">
  <%= f.submit "Sign up", class: button_class(variant: "expanded", theme:
  "primary") %>
</div>
<% end %> <% end %>
<!-- app/views/devise/registrations/edit.html.erb -->
<%= render layout: "auth_layout", locals: { title: "Edit account" } do %> <%=
form_for(resource, as: resource_name, url: registration_path(resource_name),
html: { method: :put }, data: { turbo: false }) do |f| %> <%= render
"devise/shared/error_messages", resource: resource %>

<div class="mb-6">
  <%= f.label :name, class: label_class %> <%= f.text_field :name, class:
  input_class %>
</div>

<div class="mb-6">
  <%= f.label :email, class: label_class %> <%= f.email_field :email,
  autocomplete: "email", class: input_class %>
</div>

<%# Add this field %>
<div class="mb-6">
  <%= f.label :booking_link, class: label_class %> <%= f.text_field
  :booking_link, class: input_class %>
</div>

<div class="mb-6">
  <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
  <p>Currently waiting confirmation for: <%= resource.unconfirmed_email %></p>
  <% end %>
</div>

<div class="mb-6">
  <%= f.label :password, class: label_class %> <%= f.password_field :password,
  autocomplete: "new-password", class: input_class %>
  <p class="pt-1 text-xs italic text-gray-500">
    <% if @minimum_password_length %> <%= @minimum_password_length %> characters
    minimum <% end %> (leave blank if you don't want to change it)
  </p>
</div>

<div class="mb-6">
  <%= f.label :password_confirmation, class: label_class %> <%= f.password_field
  :password_confirmation, autocomplete: "new-password", class: input_class %>
</div>

<div class="mb-6">
  <%= f.label :current_password, class: label_class %> <%= f.password_field
  :current_password, autocomplete: "current-password", class: input_class %>
  <p class="pt-2 text-sm italic text-grey-dark">
    (we need your current password to confirm your changes)
  </p>
</div>

<div class="mb-6">
  <%= f.submit "Update", class: button_class(theme: "primary", variant:
  "expanded") %>
</div>
<% end %>

<div class="mb-6">
  <hr class="mb-6" />
  <%= button_to "Cancel my account", registration_path(resource_name), data: {
  confirm: "Are you sure?" }, method: :delete, class: button_class(variant:
  "small", additional_classes: "border") %>
</div>
<% end %>

A new user who signs up can also create a unique booking link.

Model relationships

With that addition out of the way, we should focus on the relationship logic within our new models.

The User model will remain pretty straightforward. A given user can create multiple booking types, and I added validation for the booking_link on the model layer. This will ensure a booking link is present when a new user gets created.

# app/models/user.rb
class User < ApplicationRecord
  has_person_name

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :booking_types

  validates :booking_link, presence: true
end

Since a User can have many booking types, a BookingType will belong to a single user. A Booking type can have multiple bookings associated with it.

Below I add a has_rich_text association which taps into ActionText and ActiveStorage from rails. These are built these days, so you needn't do a lot of configuration to get things set up correctly.

# app/models/booking_type.rb
class BookingType < ApplicationRecord
  belongs_to :user
  has_rich_text :description
  has_many :bookings
end

Our Booking model will belong to a BookingType. We'll also reuse the has_person_name include from the has_person_name gem. I include this in my template as it's handy.

You'll see additional validations that ensure values are present before a new booking is created.

You will also see an Enum, a convenient pattern to manipulate state in ruby and rails. I have another article explaining how Enums work if you want to understand it more.

I set three different statuses to start with using a hash syntax with Ruby. These can grow as needed but be sure the number associated with the status doesn't change. If you recall, we set a default value to 0 for the status column when we first generated the booking table. This means each new booking will always be pending when first created.

I did this for a few reasons, mainly for the upcoming payments section. A new booking should only get created if a payment has succeeded, which is something we will leverage the Stripe API and webhooks for coming up.

class Booking < ApplicationRecord
  has_person_name
  belongs_to :booking_type
  has_rich_text :notes

  validates :start_at, :end_at, :booking_type_id, :name, :email, presence: true

  enum status: { pending: 0, approved: 1, unapproved: 2 }
end

Booking types controller logic

Our scaffold did a lot of work for us on the BookingType model. We have a controller with some pre-determined actions and logic, which works great to start with. I want to amend the controller code a bit to match our needs. We will pay attention to the new and create actions.

# app/controllers/booking_types_controller.rb

class BookingTypesController < ApplicationController
  before_action :set_booking_type, only: %i[ show edit update destroy ]

  # GET /booking_types or /booking_types.json
  def index
    @booking_types = current_user.booking_types
  end

  # GET /booking_types/1 or /booking_types/1.json
  def show
  end

  # GET /booking_types/new
  def new
    @booking_type = current_user.booking_types.new
  end

  # GET /booking_types/1/edit
  def edit
  end

  # POST /booking_types or /booking_types.json
  def create
    @booking_type = current_user.booking_types.new(booking_type_params.merge(user: current_user))

    respond_to do |format|
      if @booking_type.save
        format.html { redirect_to root_path, notice: "Booking type was successfully created." }

      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /booking_types/1 or /booking_types/1.json
  def update
    respond_to do |format|
      if @booking_type.update(booking_type_params)
        format.html { redirect_to root_path, notice: "Booking type was successfully updated."}
      else
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /booking_types/1 or /booking_types/1.json
  def destroy
    @booking_type.destroy

    respond_to do |format|
      format.html { redirect_to root_url, notice: "Booking type was successfully destroyed."}
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_booking_type
      @booking_type = BookingType.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def booking_type_params
      params.require(:booking_type).permit(:name, :location, :description, :color, :duration, :payment_required, :price)
    end
end

On all redirect_to methods, I changed the path to root_url. This will take any user back to the dashboard action we set previously in our routes file.

On the new action, I set up a new instance of booking types, which relates directly to the logged-in user. A user needs to sign in to get to this path so that we will have access to current_user by default.

Setting up a new instance with ruby looks as simple as:

def new
  @booking_type = current_user.booking_types.new
end

The create action does a little more lifting as we'll need to save and assign the booking type to include the current user using the same method within the new action. The new way accepts attributes which you can find at the bottom of the file. These have been whitelisted as "safe" to pass into our database. If you decide to extend this, you'll need to declare any additional fields.

# app/controllers/booking_types_controller.rb
def create
    @booking_type = current_user.booking_types.new(booking_type_params)

    respond_to do |format|
      if @booking_type.save
        format.html { redirect_to root_path, notice: "Booking type was successfully created." }

      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

Creating a new booking type can happen at locahost:3000/booking_types/new. Head there, and you should see an ugly form. We can spruce this up a bit.

<!- app/views/booking_types/new.html.erb-->
<div class="max-w-3xl mx-auto px-4">
  <div class="flex items-center justify-between">
    <h1 class="font-bold text-3xl mb-6">Create a booking type</h1>
    <% if current_user.booking_link? %>
      <%= link_to "View live page", user_path(booking_link: current_user.booking_link), class: "text-gray-700 underline" %>
    <% end%>
  </div>

  <%= render 'form', booking_type: @booking_type %>
</div>

The template above uses a _form.html.erb partial found in the same view folder. We include it with an instance of @booking_type.

<!-- app/views/booking_types/_form.html.erb -->
<%= form_with(model: booking_type) do |form| %>
  <% if booking_type.errors.any? %>
    <div id="error_explanation" class="bg-red-50 p-6 rounded text-red-800">
      <h2 class="text-lg font-semibold mb-3"><%= pluralize(booking_type.errors.count, "error") %> prohibited this booking_type from being saved:</h2>

      <ul class="list-disc leading-relaxed">
        <% booking_type.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :name, class: label_class %>
    <%= form.text_field :name, class: input_class %>
  </div>

  <div class="mb-6">
    <%= form.label :location, class: label_class %>
    <%= form.text_field :location, class: input_class %>
  </div>

  <div class="mb-6">
    <%= form.label :description, class: label_class %>
    <%= form.rich_text_area :description, class: input_class %>
  </div>

  <div class="mb-6">
    <%= form.label :color, class: label_class %>
    <%= form.text_field :color, class: input_class %>
  </div>

  <div class="mb-6">
    <%= form.label :duration, class: label_class %>
    <%= form.number_field :duration, class: input_class %>
  </div>

  <div class="mb-6 flex items-center space-x-2">
    <%= form.check_box :payment_required, class: checkbox_class %>
    <%= form.label :payment_required, class: label_class %>
  </div>

  <div class="mb-6 lg:w-1/4">
    <%= form.label :price, class: label_class %>
    <%= form.number_field :price, class: input_class %>
  </div>

  <%= form.submit class: button_class(theme: 'primary') %>

  <% if booking_type.persisted? %>
    <%= link_to "Cancel", booking_type, class: "text-neutral-700 underline inline-block ml-3" %>
  <% else %>
    <%= link_to "Cancel", booking_types_path, class:  "text-neutral-700 underline inline-block ml-3" %>
  <% end %>
<% end %>

Displaying booking types in the dashboard

The dashboard will be where most users do their work with the app. We'll use it to display booking types and any bookings that might have come in, and I'll follow up to show the bookings logic.

<!-- app/views/home/dashboard.html.erb-->
<div class="container mx-auto px-4 pb-32">
  <div class="flex items-center justify-between pb-6 border-b">
    <h1 class="font-extrabold text-3xl">Dashboard</h1>
    <%= link_to "Create booking type", new_booking_type_path, class: button_class(theme: "primary") %>
  </div>
  <div class="py-10 grid grid-cols-3 gap-6">
    <%= render @booking_types %>
  </div>
</div>

The render @booking_types is a nifty feature from rails that looks into the app/views/booking_types folder for a partial named _booking_type.html.erb and renders it as a looped instance. So with very few keystrokes, you automatically get a lot of work done.

<!-- app/views/booking_types/_booking_type.html.erb-->
<div id="<%= dom_id booking_type %>">
  <%= link_to edit_booking_type_path(booking_type), class: "p-6 border shadow-xl rounded-xl block hover:shadow-lg transition ease-in duration-300 relative overflow-hidden" do %>
    <div class="h-1 w-full absolute top-0 left-0 right-0" style="background-color: <%= booking_type.color %>"></div>
    <h3 class="font-medium text-2xl"><%= booking_type.name %></h3>
    <p class="text-gray-500">View booking page</p>
    <p><%= duration(booking_type) %></p>

    <% if booking_type.payment_required? %>
      <div class="mt-3 pt-3 border-t">
        <p>
          <strong>Payment required</strong>
        </p>
        <p>
          <strong>Price:</strong>
          <%= number_to_currency(booking_type.price) %>
        </p>
      </div>
    <% end %>
  <% end %>
</div>

This UI will display as a container which a custom color. If a payment is required, we conditionally add that as well.

Seeding data

It makes sense to pave a few roads regarding data to save time and headaches. I tend to do this repeatedly when I see myself keying in data inside forms, and that very issue is what seeds solve.

Head to db/seeds.rb. Inside, you can write basic Rails/Ruby code to create data with code rather than UI. Below is how mine ended up.

# db/seeds.rb

# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
#   movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }])
#   Character.create(name: "Luke", movie: movies.first)

User.destroy_all
BookingType.destroy_all

user = User.create!(
  booking_link: "jsmith",
  email: "[email protected]",
  name: "John Smith,"
  password: "password",
  password_confirmation: "password"
)

booking_type1 = BookingType.create!(
  color: "#38bdf8",
  description: "15 min Test 123",
  duration: 15,
  location: "Zoom",
  name: "15min",
  payment_required: false,
  price: nil,
  user: user
)

booking_type2 = BookingType.create!(
  color: "#fbbf24",
  description: "30 min Test 123",
  duration: 30,
  location: "Zoom",
  name: "30min",
  payment_required: false,
  price: nil,
  user: user
)

booking_type3 = BookingType.create!(
  color: "#34d399",
  description: "1 hour Test 123",
  duration: 60,
  location: "Zoom",
  name: "60min",
  payment_required: true,
  price: 125,
  user: user
)

puts "Reset complete 👨‍💻🎉🔥"

Now you can safely run rails db:reset (assuming you quit your server), and the database will migrate and create this new sample data for development. You can log in with the user data in the seeds file and see all three booking types we added.

Booking link routing

Let's add the dynamic booking link as a route for users. To do this, we'll need to add some routing and an additional users_controller.rb file.


require 'sidekiq/web'

Rails.application.routes.draw do

    authenticate :user, lambda { |u| u.admin? } do
      mount Sidekiq::Web => '/sidekiq'
    end

  devise_for :users

  authenticated :user do
    root to: "home#dashboard", as: :authenticated_root
  end

  resources :booking_types
  resources :bookings, except: [:index, :new]

  get ":booking_link", to: "users#show", as: :user

  scope '/:booking_link', as: :user do
    resources :bookings, only: [:index, :new]
  end

  root to: "home#index"
end

Above is my modified routing file that adapts to the patterns I'm going for. We'll first create a unique get request to :booking_link and assign it to the users_controller#show action. This file doesn't yet exist, so we'll create it next.

The new route we just added will assume any path is now a booking link, so be sure to add it towards the end of the routes.rb file. Additionally, we need to scope that link to allow additional booking paths. This will enable us to scope the shared link as/username/bookings/new instead of the default /bookings/new. This seems more friendly from a user experience perspective.

Create a users controller

While Devise handles much of the heavy lifting regarding user logic, we can still extend it a bit to add a few additional actions. For instance, the show action is free and clear, whereas all other actions are not.

I'll create a new file in app/controllers called users_controller.rb and add the following code.

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def show
    @user = User.find_by(booking_link: params[:booking_link])
  end
end

Then we can add a new view inside app/views/users/show.html.erb

<!-- app/views/users/show.html.erb -->
<div class="max-w-5xl mx-auto pb-8 rounded shadow-xl border">
  <div class="px-6 py-10 rounded-t-xl bg-gray-50">
    <h1 class="text-3xl font-extrabold text-center"><%= @user.name %>'s Booking Page</h1>
    <p class="text-center text-gray-600 mt-2">Book your prefered time slot using the booking options below.</p>
  </div>
  <div class="grid grid-cols-3 gap-6 px-8 pt-8">
    <% @user.booking_types.each do |booking_type| %>
      <%= render "users/booking_type", booking_type: booking_type %>
    <% end %>
  </div>
</div>

This page will be the unique page any user can share if they have an account with booking types. Think of this as the public-facing part of the app people can come schedule some time with you.

<!-- app/views/users/_booking_type.html.erb-->
<div id="<%= dom_id booking_type %>">
  <%= link_to new_user_booking_path(booking_link: @user.booking_link, booking_type: booking_type.name.parameterize), class: "p-6 border shadow-xl rounded-xl block hover:shadow-lg transition ease-in duration-300 relative overflow-hidden" do %>
    <div class="h-1 w-full absolute top-0 left-0 right-0" style="background-color: <%= booking_type.color %>"></div>
    <h3 class="font-medium text-2xl"><%= booking_type.name %></h3>
    <p class="text-gray-500">View booking page</p>
    <p><%= duration(booking_type) %></p>

    <% if booking_type.payment_required? %>
      <div class="mt-3 pt-3 border-t">
        <p>
          <strong>Payment required</strong>
        </p>
        <p>
          <strong>Price:</strong>
          <%= number_to_currency(booking_type.price) %>
        </p>
      </div>
    <% end %>
  <% end %>
</div>

We'll mimic the file's contents found in the app/views/booking_types/booking_type.html.erb file but change the link to be more dynamic. Inside the link_to helper, we use the new_user_booking_path helper method and pass in some parameters, including the booking_link and the booking_type. We'll be able to extract this on the booking page, so there's a "review" type of page before creating a new Booking.

Bookings Controller

Now that we have a space to create bookings, we extend the bookings_controller.rb a touch to account for the logic we need.

class BookingsController < ApplicationController
  before_action :set_booking, only: %i[ show edit update destroy ]

  # GET /bookings/1 or /bookings/1.json
  def show
  end

  # GET /bookings/new
  def new
    @booking = Booking.new
    @booking_type = BookingType.find_by(name: params[:booking_type])
  end

  # GET /bookings/1/edit
  def edit
  end

  # POST /bookings or /bookings.json
  def create
    @booking = Booking.new(booking_params)
    @booking_type = BookingType.find(params[:booking][:booking_type_id])

    respond_to do |format|
      if @booking.save

        unless @booking_type.payment_required?
          @booking.approved!
        end

        format.html { redirect_to root_url, notice: "Booking was successfully created."}
      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /bookings/1 or /bookings/1.json
  def update
    respond_to do |format|
      if @booking.update(booking_params)
        format.html { redirect_to root_url, notice: "Booking was successfully updated." }
      else
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /bookings/1 or /bookings/1.json
  def destroy
    @booking.destroy

    respond_to do |format|
      format.html { redirect_to bookings_url, notice: "Booking was successfully destroyed."}
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_booking
      @booking = Booking.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def booking_params
      params.require(:booking).permit(:booking_type_id, :status, :name, :email, :start_at, :end_at, :notes)
    end
end

Then, we can extend the form in the app/views/bookings/new.html.erb template.

<div class="max-w-3xl mx-auto px-4">
  <% if @booking_type.payment_required? %>
      <h1 class="font-bold text-3xl mb-6">Schedule your booking for <%= number_to_currency(@booking_type.price) %></h1>

      <%# future payment form %>

      <%= render 'form', booking: @booking %>
    </div>
  <% else %>
    <h1 class="font-bold text-3xl mb-6">Schedule your booking</h1>

    <%= render 'form', booking: @booking %>
  <% end %>
</div>

And the booking form

<%= form_with(model: booking, data: { turbo: false) do |form| %>
  <% if booking.errors.any? %>
    <div id="error_explanation" class="bg-red-50 p-6 rounded text-red-800">
      <h2 class="text-lg font-semibold mb-3"><%= pluralize(booking.errors.count, "error") %> prohibited this booking from being saved:</h2>

      <ul class="list-disc leading-relaxed">
        <% booking.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <h3 class="font-bold text-lg mb-3 mt-6">Personal details</h3>

  <div class="grid grid-col-1 lg:grid-cols-2 gap-6 mb-10">
    <div>
      <%= form.label :name, class: label_class %>
      <%= form.text_field :name, class: input_class %>
    </div>

    <div>
      <%= form.label :email, class: label_class %>
      <%= form.text_field :email, class: input_class %>
    </div>
  </div>

  <div class="mb-6">
    <%= form.label :notes, class: label_class %>
    <%= form.rich_text_area :notes, class: input_class %>
  </div>

  <h3 class="font-bold text-lg mb-3">Booking details</h3>

  <% if @booking.new_record? %>
    <div class="mb-6">
      <div class="rounded border-slate-300 border shadow-sm bg-white py-6 px-10 inline-block relative">
        <%= form.label :booking_type_id, "Booking type", class: label_class %>
        <input type="hidden" name="booking[booking_type_id]" value="<%= @booking_type.id %>">
        <div class="flex items-start">
          <div class="text-3xl font-bold mr-3"><%= params[:booking_type] %>
            <div class="absolute top-3 right-3 w-3 h-3 rounded-full" style="background-color: <%= @booking_type.color %>;"></div>
          </div>
        </div>
      </div>
    </div>
  <% else %>
    <%= @booking.booking_type.name %>
  <% end %>

  <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
    <div>
      <%= form.label :start_at, class: label_class %>
      <%= form.datetime_field :start_at, class: input_class, min: Date.today %>
    </div>

    <div>
      <%= form.label :end_at, class: label_class %>
      <%= form.datetime_field :end_at, class: input_class, min: Date.today %>
    </div>
  </div>

  <%= form.submit @booking_type.payment_required? ? "Schedule booking for #{number_to_currency(@booking_type.price)}" : "Schedule booking", class: button_class(theme: 'primary') %>
<% end %>

The view helpers you see like input_class and label_class can be found in app/helpers/application_helper.rb. I simply extracted some Tailwind CSS classes into smaller methods for less mess.

Setting up payments

With the core logic of the app underway, I wanted to allow a user to charge for a booking. Doing so presents some challenges but it's possible to do it thanks to Stripe. I wanted a solution that felt native to the Rails form, so I went with Stripe Elements rather than their drop-in Stripe Checkout UI. Elements require more code but can be customized much more.

You'll need to be sure to install the Stripe gem, have a Stripe account, and grab your API keys.

Adding your keys to the app can be trivial but also tricky. I use Rails application credentials to do this work since they are encrypted and easier to reference, assuming you have a way to decrypt.

Inside my credentials file, I added my keys like the following:

# assuming you use VS code as a code editor
EDITOR="code --wait" rails credentials:edit

If a new file opens, it has been successfully decrypted. I added the following YAML setup.

stripe:
  public_key: XXXXXXXXXXXXXXXXX
  secret_key: XXXXXXXXXXXXXXXXX
  whsc: XXXXXXXXXXXXXXXXX

Then closed the file. The keys here will need to coincide with your account on Stripe. Be sure to use your test mode keys since we aren't building this app for production.

I'll update the _head.html.erb file in the app/views/shared/_head.html.erb.

<!-- app/views/shared/_head.html.erb-->
 <head>
  <title>
    <%= content_for?(:title) ? yield(:title) : "Kickoff Tailwind" %>
  </title>

  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="stripe-public-key" content="<%= Rails.application.credentials.dig(:stripe, :public_key) %>">
  <%= stylesheet_link_tag  'application', 'actiontext', 'data-turbolinks-track': 'reload' %>
  <%= javascript_include_tag "application", defer: true %>
  <%= javascript_include_tag 'https://js.stripe.com/v3/', 'data-turbolinks-track': 'reload' %>
</head>

Note the addition of the line <meta name="stripe-public-key" content="<%= Rails.application.credentials.dig(:stripe, :public_key) %>">. We display the public key in the HTML to grab it with JavaScript. Be sure to load the js.stripe.com/v3 javascript as well. (<%= javascript_include_tag 'https://js.stripe.com/v3/', 'data-turbolinks-track': 'reload' %>).

Set the Stripe API key

I'm lazy here, so I include a call to set up the Stripe.api_key with each request. This occurs in the application_controller.rb file as a private method. Remember to set the before_action :set_stripe_key.

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :configure_permitted_parameters, if: :devise_controller?
  before_action :set_stripe_key

  private

    def set_stripe_key
      Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
    end

  protected

    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :booking_link])
      devise_parameter_sanitizer.permit(:account_update, keys: [:name, :booking_link])
    end
end

Create a PaymentIntent

I need a new `POST route to create a PaymentIntent with Stripe. The PaymentIntent returns a secure key to process a transaction in a much more secure way than what we used to do.

# config/routes.rb

Rails.application.routes.draw do
  post "payment-intent", to: "bookings#intent"
end

I mapped this route to our bookings_controller since those things relate. We'll need to create a new action inside the controller with the following code:

class BookingsController < ApplicationController
  def intent
    @booking_type = BookingType.find(params[:_json])
    amount = @booking_type.price * 100

    payment_intent = Stripe::PaymentIntent.create(
      amount: amount,
      currency: 'usd',
      automatic_payment_methods: {
        enabled: true
      },
      metadata: {
        user_id: @booking_type.user.id,
        booking_type_id: @booking_type.id
      }
    )

    respond_to do |format|
      format.json { render json: { clientSecret: payment_intent['client_secret'] } }
    end
  end
end

Using the Stripe gem, we hook into the API and return a client secret as JSON. This will return to our booking form and help us render the payment element correctly. In this setup process, you can pass additional metadata, of which I give both the booking_type_id and the user_id for reference. We can refer to these fields later when it comes time to validate a payment was successful using webhooks.

I'll be using Stimulus.js for the JavaScript portion of this tutorial. It now comes stock with Rails, so I figured it makes the most sense. I also enjoy the framework as it removes some nasty parts of the JavaScript language I'm not wild about.

Create a new file called payments_controller.js inside app/javascript/controllers/.

Inside the index.js file within app/javascript/, add the following:

// app/javascript/index.js
import { application } from "./application"

import PaymentController from "./payment_controller"
application.register("payment", PaymentController)

This registers the new controller making it ready for use.

Inside our app/views/bookings/new.html.erb file, we need to update the view to the following:

<!-- app/views/bookings/new.html.erb-->
<div class="max-w-3xl mx-auto px-4">
  <% if @booking_type.payment_required? %>
    <div
      data-controller="payment"
      data-payment-item-value="<%= @booking_type.id %>"
      data-payment-return-url-value="<%= root_url %>"
    >
      <h1 class="font-bold text-3xl mb-6">Schedule your booking for <%= number_to_currency(@booking_type.price) %></h1>

      <div clas="mb-10">
        <h3 class="font-bold text-xl mb-3">Enter payment details</h3>

        <div id="payment-element" data-payment-target="element"></div>

        <div data-payment-target="message" class="hidden"></div>
      </div>

      <%= render 'form', booking: @booking %>
    </div>
  <% else %>
    <h1 class="font-bold text-3xl mb-6">Schedule your booking</h1>

    <%= render 'form', booking: @booking %>
  <% end %>
</div>

The data-controller attribute tells stimulus to initialize the new controller we just made, thus scoping all our JavaScript between the opening and closing div tag where the data-controller attribute lives.

We can pass additional values to the controller using a naming convention that includes payments and a new name for the value itself. So data-payment-item-value will turn into itemValue within the controller. More on this to come.

Inside the conditional where I check, if payment is required, I added additional HTML elements which will represent where our payment form displays. Using a target we can tap into the element with JavaScript and manipulate things about it. We'll use the data-payment-target="element" div to mount the Stripe Payment Element to.

Add JavaScript and Fetch Stripe API

Below is the final JavaScript to POST the Rails app for a Payment Intent client secret key and handles payments asynchronously. We need to tell Rails to accept the JSON data by including the CSRF token in the fetch API post request. Doing this means Rails will consider the request valid and get the submission.

If a client secret successfully returns, we can mount the PaymentElement.

Finally, we use the confirmPayment method from the Stripe.js library to handle the rest of the work executing the form.

import { Controller } from "@hotwired/stimulus"

let elements
const csrfToken = document.getElementsByName("csrf-token")[0].content

const stripe = Stripe(
  document.getElementsByName("stripe-public-key")[0].content
)

export default class extends Controller {
  static values = {
    item: String,
    returnURl: String,
  }

  static targets = ["element", "submit", "form", "name", "email"]

  async initialize() {
    const response = await fetch("/payment-intent", {
      method: "POST",
      headers: {
        "X-CSRF-Token": csrfToken,
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(this.itemValue),
    })

    const { clientSecret } = await response.json()
    const appearance = {
      theme: "stripe",
    }
    elements = stripe.elements({ appearance, clientSecret })

    const paymentElement = elements.create("payment")
    paymentElement.mount(this.elementTarget)
  }

  async purchase(event) {
    event.preventDefault()
    this.setLoading(true)

    const { error } = await stripe.confirmPayment({
      elements,
      redirect: "if_required",
    })

    if (error) {
      if (error.type === "card_error" || error.type === "validation_error") {
        this.showMessage(error.message)
      } else {
        this.showMessage("An unexpected error occured")
      }
    } else {
      this.formTarget.submit()
      this.setLoading(false)
    }
  }

  setLoading(isLoading) {
    if (isLoading) {
      // Disable the button and swap text
      this.submitTarget.disabled = true
      this.submitTarget.classList.add("opacity-50")
      this.submitTarget.value = "Processing..."
    } else {
      this.submitTarget.disabled = false
      this.submitTarget.classList.remove("opacity-50")
      this.submitTarget.value = "Schedule booking"
    }
  }

  showMessage(messageText) {
    this.messageTarget.classList.remove("hidden")
    this.messageTarget.textContent = messageText

    setTimeout(() => {
      this.messageTarget.classList.add("hidden")
      this.messageTarget.textContent = ""
    }, 4000)
  }
}

Webhooks

Even though the form has been submitted, we still need to confirm a purchase is complete. Doing this will require webhooks. Webhooks are a way to listen for different events from an API and, through those events, tap into data that will tell you more about activity that has or is occurring at any given moment. Think of it as a way for apps to talk to each other without actually being connected through code.

First, we need to add one more route:

Rails.application.routes.draw do
  resources :webhooks, only: :create
end

We only need the create action as this will be an incoming post request.

Next, we require a new controller, given how we set up the routing. Create a new controller called webhooks_controller.rb inside app/controllers/.

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    signature_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :whsc)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, signature_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      render json: {message: e}, status: 400
      return
    rescue Stripe::SignatureVerificationError => e
      render json: {message: e}, status: 400
      return
    end

    case event.type
    when 'payment_intent.succeeded'
      # Find bookings
      @user = User.find(event.data.object.metadata['user_id'])
      @bookings = Booking.where(booking_type_id: @user.booking_type_ids)
      @booking = @bookings.last
      @booking.update(customer_paid: true, status: "approved")
    when 'payment_intent.processing'
      # Send email to users notifying them we are processing their payment and will get another reminder or something when payment completes.
    when 'payment_intent.payment_failed'
      # Send email to users notifying them their payment failed and booking was not scheduled. Be sure to unapproved booking.
    else
      puts "Unhandled event type: #{event.type}"
    end
  end
end

In this controller, we'll use the skip_before_action :verify_authenticity_token callback to allow the events to come into the app. We aren't saving these events to the database, so accepting them is not a huge deal. Of course, it would be best if you only did this for trusted APIs.

Inside the create action, I referred to the Stripe documentation to deliver much of what you see. When certain event types come through, you can perform logic. The main thing I wanted to confirm is if a payment_intent succeeded. If so, we can assume the charge is complete. If that's the case, we can find the given booking using the metadata we passed previously and updated the status to approved, and toggle the customer_paid boolean to true.

Other events here are helpful if the payment is still processing or failing. I'd recommend sending emails to those folks in those specific cases, and I outlined what that might look like in the code comments.

Final thoughts

Now I get it, this app is quite simple, but it still accomplishes the goal extremely scrappy way. From here, I invite you to extend this in any way you see fit. The source code is linked to the right. Thanks for reading. Happy coding!

Link this article
Est. reading time: 32 minutes
Stats: 10,097 views

Categories

Collection

Part of the Let's Build: With Ruby on Rails collection