February 12, 2020

2 responses

Let's Build: With Ruby on Rails - Marketplace App with Stripe Connect

Welcome to another installment of my Let's Build: With Ruby on Rails series. This series focuses on building a marketplace application using Stripe Connect. Users can sign up, purchase, and sell their own goods and services while the application takes a percentage of each transaction.

Back My Idea

Our fictitious marketplace app is called Back My Idea. Back my Idea is an app similar to GoFundMe and Kickstarter where users can crowdsource/fund their way to building their next big idea. Entrepreneurs/Designers/Developers can pitch their concept of an app and ask for donations to help fund their process of building it.

Download the source code

Core Requirements:

  • A User must be logged in to manipulate any part of the platform.
  • A User can have two roles (three if you count admin). A maker and a backer are those roles. Backers provide funding via payment forms (Stripe) to fund the Maker's venture. The Maker can receive direct funding from Backers with an amount set aside for the app to take a cut. Any Backer can also be a Maker and vice versa.
  • A Maker can post their project and save it as a draft or public. Once public, the project can be backed by the Backer.
  • A User can't back their own project directly but can edit content.
  • All projects have an expiration date of 30 days.
  • A Project can't be edited by a Backer unless it's their own.
  • A Project can be commented on by both a Maker and a Backer.
  • A Project can have perks as stacked backing amounts.
  • Perks can be any number dictated by the Maker
  • Each Perk represents a single transaction

Stack

  • Ruby on Rails
  • Stimulus JS
  • Tailwind CSS

Modeling:

  • User
    • Roles: Admin, Maker, Backer
    • Username
    • Name
    • Email
    • Password
  • Project
    • Title
    • Description
    • Donation Goal
    • Commentable Type
    • Commentable ID
    • User ID
  • Perk - Relative to Projects but seperated (nested attributes?)
    • Title
    • Amount
    • Description
    • Amount Available
    • Project ID
  • Comment - Polymorphic
    • Body

Part 1

Part 2

Part 3

Part 4

Part 5

Part 6

Part 7

Part 8

Getting Started

This tutorial assumes you have some knowledge of creating apps with Ruby on Rails. If not, I strongly suggest checking out some of my other more beginner Let's Builds. Each build gets a bit more challenging as you progress. Really stuck on how to use Ruby on Rails? I made a whole 90 video course to help you get "unstuck" at https://hellorails.io.

Tooling

I'll be using Ruby, Rails, rbenv, VS Code, and iTerm to make this tutorial. You're welcome to customize that tool stack to your heart's content. I'm forgoing making this any more challenging or complicated than it needs to be.

To help even more with this (and to save a boat load of time on my end), I made a Rails application template I call Kickoff Tailwind. You can use it as a starting point to get up and running fast with a new Rails app. The template leverages Devise, Tailwind CSS, Friendly Id, Sidekiq, and custom templates view from the GitHub repo. This works with Rails 5.2+. Check the github repo for more information.

Proposed Data Architecture

After a quick brainstorming session I landed on what kind of data I might need I compiled a generalized list. This will most likely change as I progress. I prefer doing this upfront when I'm not touching code. It allows me to think in a different manner which ultimately means less trial and error later.

  • User - Model Free from Devise
    • Roles: Admin, Maker, Backer
    • Username (free from my kickoff tailwind template)
    • Name (free from my kickoff tailwind template)
    • Email (free from devise)
    • Password (free from Devise)
  • Project
    • Title - string
    • Description - rich_textarea (action text)
    • Pledge Goal - string
    • Pledge Goal Ends At - date time
    • Commentable Type - string
    • Commentable ID - bigint
    • User ID - integer
  • Perk - Relative to Projects but seperated (nested attributes?)
    • Title - string
    • Amount - decimal
    • Description - rich_textarea (action text)
    • Amount Available - integer
    • Project ID - integer
  • Comment - Polymorphic
    • Body

Creating projects

To kick things off I'll generate our Project model scaffold with a title, donation goal and user association. We'll add the description field later as an action text feature in Rails 6. We will also add polymorphic comments in the event you need comments elsewhere in the future.

$ rails g scaffold Project title:string donation_goal:decimal user:references

Running via Spring preloader in process 31754
      invoke  active_record
      create    db/migrate/20190829024529_create_projects.rb
      create    app/models/project.rb
      invoke    test_unit
      create      test/models/project_test.rb
      create      test/fixtures/projects.yml
      invoke  resource_route
       route    resources :projects
      invoke  scaffold_controller
      create    app/controllers/projects_controller.rb
      invoke    erb
      create      app/views/projects
      create      app/views/projects/index.html.erb
      create      app/views/projects/edit.html.erb
      create      app/views/projects/show.html.erb
      create      app/views/projects/new.html.erb
      create      app/views/projects/_form.html.erb
      invoke    test_unit
      create      test/controllers/projects_controller_test.rb
      create      test/system/projects_test.rb
      invoke    helper
      create      app/helpers/projects_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/projects/index.json.jbuilder
      create      app/views/projects/show.json.jbuilder
      create      app/views/projects/_project.json.jbuilder
      invoke  assets
      invoke    scss
      create      app/assets/stylesheets/projects.scss
      invoke  scss
      create    app/assets/stylesheets/scaffolds.scss

The command above generates a ton of files. I'll delete the scaffold.scss file and the project.scss file that get generated since we don't need either. This tutorial won't be test driven. While I realize it's important to practice TDD where possible the scope of this guide is to teach you to build out the idea using practical Rails concepts. Later on tests can and should be a focus.

I'll add a description field later via action text. Commenting will also come later!

Let's migrate. Notice when you do a new file in db/ is born called schema.rb.

$ rails db:migrate

== 20190829024529 CreateProjects: migrating ===================================
-- create_table(:projects)
   -> 0.0046s
== 20190829024529 CreateProjects: migrated (0.0049s) ==========================

Thanks to our user:references option we now have a belongs_to :user association within the app/model/project.rb file (within the Ruby class). This tells ActiveRecord how to associate our User model to our Project model. By using my kickoff template and Devise we got our User model for free. If you didn't use those templates (it's totally fine if not), you'll need to generate a new user model and install Devise before going forward.

# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :user
end

We still need to declare a has_many association on the user model to make this all work.

# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_many :projects, dependent: :destroy # add this line
end

What's happening here is that we expect a user to be able to create and associate themselves to many projects. Doing so happens on the Project model. When I ran user:references a new column was added to the database. If you check out that migration that was generated you'll see the following:

# db/migrate/20190829024529_create_projects.rb
class CreateProjects < ActiveRecord::Migration[6.0]
  def change
    create_table :projects do |t|
      t.string :title
      t.string :donation_goal
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end

The t.references method is a wrapper around some SQL that will generate a n user_id column on the projects table in the database. Since we already migrated our new data types you can refer to the schema.rb file for a better visual of what was added. Remember not to edit this file directly.

# db/schema.rb
ActiveRecord::Schema.define(version: 2019_08_29_024529) do
    ... # code omitted for clarity sake

  create_table "projects", force: :cascade do |t|
    t.string "title"
    t.string "donation_goal"
    t.integer "user_id", null: false # added thanks to user:references
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["user_id"], name: "index_projects_on_user_id" # added thanks to user:references
  end

  create_table "users", force: :cascade do |t|
    t.string "email", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.string "reset_password_token"
    t.datetime "reset_password_sent_at"
    t.datetime "remember_created_at"
    t.string "username"
    t.string "name"
    t.boolean "admin", default: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
  end

  add_foreign_key "projects", "users"
end

Updating Routes

Since the focus of this app will be on projects it probably makes sense to make the project index path the root path of the app. This can be done by updating the config/routes.rb file to the following:

# config/routes.rb

require 'sidekiq/web'

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


  devise_for :users
  root to: 'projects#index' # changed from 'home#index'
end

Now the root path should update to an ugly looking project table listing. Clicking New Project should direct you to localhost:3000/projects/new where a form awaits. You can remove the User Id field altogether.

Some initial UI Love

I added some basic styles to make this look a little more presentable at this point. I chose a darker background for grins. Feel free to modify the styles in any way you please. If you're following along and using my Kickoff Tailwind template the next section will apply to you directly.

/* app/javascript/stylesheets/components/_forms.scss */
.input {
  @apply appearance-none block w-full bg-white text-gray-800 rounded py-3 px-4 leading-tight;

  &.input-with-border {
    @apply border;
  }
}

.input:focus {
  @apply outline-none bg-white;
}

.label {
  @apply block text-white font-bold mb-2;
}

.select {
  @apply appearance-none py-3 px-4 pr-8 block w-full bg-white text-gray-800
   rounded leading-tight border-2 border-transparent;
  -webkit-appearance: none;
}

.select:focus {
  @apply outline-none bg-white;
}

And the buttons get a little love as well.

/* app/javascript/stylesheets/components/_buttons.scss */

/* Buttons */

.btn {
  @apply font-semibold text-sm py-2 px-4 rounded cursor-pointer no-underline inline-block;

  &.btn-sm {
    @apply text-xs py-1 px-3;
  }

  &.btn-lg {
    @apply text-base py-3 px-4;
  }

  &.btn-expanded {
    @apply block w-full text-center;
  }
}

.btn-default {
  @apply bg-blue-600 text-white;

  &:hover,
  &:focus {
    @apply bg-blue-500;
  }

  &.btn-outlined {
    @apply border border-blue-600 bg-transparent text-blue-600;

    &:hover,
    &:focus {
      @apply bg-blue-600 text-white;
    }
  }
}

.btn-white {
  @apply bg-white text-blue-800;

  &:hover,
  &focus {
    @apply bg-gray-300;
  }
}

.link {
  @apply no-underline text-white;

  &:hover,
  &:focus {
    @apply text-gray-100;
  }
}

I added a new file to tweak the heading colors h1-h6 as well. This requires an import to our application.scss file.

/* app/javascript/stylesheets/components/_typography.scss */
h1,
h2,
h3,
h4,
h5,
h6 {
  @apply text-white;
}
/* app/javscript/stylesheets/application.scss */

@tailwind base;
@tailwind components;

// Custom SCSS
@import "components/buttons";
@import "components/forms"; 
@import "components/typography"; /* Add this line */

@tailwind utilities;

Finally the main application.html.erb layout gets a slight tweak to the body class only:

<!DOCTYPE html>
<html>
  <head>
    <title>Back My Idea</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <meta name="viewport" content="width=device-width, initial-scale=1">
    <%= stylesheet_link_tag  'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= stylesheet_pack_tag  'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag  'application', 'data-turbolinks-track': 'reload' %>

  </head>

 <body class="bg-blue-800 text-blue-100">
 <!-- a ton more code below: Find this in my kickoff tailwind rails application template (linked in this post) -->

Project Forms and Index

With our new form classes adjusted to match the darker color scheme we can update the templates directly to accommodate.

<!-- app/views/projects/_form.html.erb-->

<%= form_with(model: project, local: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation" class="bg-white p-6 rounded text-red-500 mb-5">
      <h2 class="text-red-500 font-bold"><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :donation_goal, class: "label" %>
    <%= form.text_field :donation_goal, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.submit class: "btn btn-default" %>
  </div>
<% end %>

Our scaffold for Projects currently shares this form partial in both projects/new.html.erb and projects/edit.html.erb so I'll update those to make this all look a little nicer.

Here's the /new template:

<!-- app/views/projects/new.html.erb -->
<div class="max-w-lg m-auto">
  <h1 class="text-3xl font-bold mb-6">Create a Project</h1>
  <%= render 'form', project: @project %>
</div>

and the /edit:

<!-- app/views/projects/edit.html.erb -->
<div class="max-w-lg m-auto">
  <h1 class="text-3xl font-bold mb-6">Edit Project</h1>
  <%= render 'form', project: @project %>
</div>

Note: Both the /new and /edit routes should definitely require a User to be logged in to view these. We'll approach that issue coming up.

Handling Errors / Authentication

Trying to create a new Project results in an error since we removed the reference to the user_id that existed originally when scaffolding the Project resource. We can associate the user on the controller side of the equation to fix this problem. As it stands, you can't create a new project.

In the real world, I need access to the user who is creating the project. I need this data so we can associate that user to the project going forward. Doing so means the user must be signed in before creating a new project. We can address this as well as the errors in our controller.

I'll update the projects_controller.rb to the following as a result:

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  before_action :set_project, only: [:show, :edit, :update, :destroy]
  before_action :authenticate_user!, except: [:index, :show]

  def index
    @projects = Project.all
  end

  def show
  end

  def new
    @project = Project.new
  end

  def edit
  end

  def create
    @project = Project.new(project_params)
    @project.user_id = current_user.id

    respond_to do |format|
      if @project.save
        format.html { redirect_to @project, notice: 'Project was successfully created.' }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @project.update(project_params)
        format.html { redirect_to @project, notice: 'Project was successfully updated.' }
        format.json { render :show, status: :ok, location: @project }
      else
        format.html { render :edit }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @project.destroy
    respond_to do |format|
      format.html { redirect_to projects_url, notice: 'Project was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    def set_project
      @project = Project.find(params[:id])
    end

    def project_params
      params.require(:project).permit(:title, :donation_goal)
    end
end

As a result, the currently logged in user's id attribute gets scoped to the project. Also be sure to adjust the before_action at the top of the class. We added a devise option called authenticate_user! which allows you to lock down any action in the class. In this case I'm white-listing index and show because anyone browsing the website will be able to see those without being logged in.

Try creating a new project now. You should be redirected to login. If so, and you haven't already, create an account and create a new project. We can verify things worked correctly using our logs and/or Rails console.

Running via Spring preloader in process 45423
Loading development environment (Rails 6.0.0)
irb(main):001:0> Project.last
   (0.6ms)  SELECT sqlite_version(*)
  Project Load (0.1ms)  SELECT "projects".* FROM "projects" ORDER BY "projects"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<Project id: 1, title: "First Project", donation_goal: "$1000", user_id: 1, created_at: "2019-08-30 19:15:13", updated_at: "2019-08-30 19:15:13">

Notice when I type Project.last we get a project back. The user_id column has 1 as a value. Since there is only one account (at least on my machine) It's safe to say our work here was a success.

Project Index

The index.html.erb file displays all projects at this point. You will need to create some dummy projects to get any data to appear. I'll make this a grid-layout with card components. Here's the initial markup:

<!-- app/views/projects/index.html.erb -->

<div class="flex flex-wrap items-start justify-start">
  <% @projects.each do |project| %>
    <div class="relative w-full p-6 border-2 border-blue-700 rounded-lg lg:w-1/4 lg:mr-8">
      <%= image_tag project.thumbnail.variant(resize_to_limit: [600, 400]), class: "rounded" if project.thumbnail.present? %>
      <h3 class="mb-2 text-2xl font-bold"><%= project.title %></h3>
      <div class="my-1"><%= truncate(strip_tags(project.description.to_s), length: 140) %></div>
      <p>Donation goal: <%= project.donation_goal %></p>
      <p class="text-sm italic opacity-75">Created by: <%= project.user.name %> </p>
      <%= link_to "View project", project, class: "btn btn-default inline-block text-center my-2" %>
      <% if author_of(project) %>
      <div class="absolute top-0 right-0 mt-2 mr-2">
        <%= link_to edit_project_path(project) do %>
          <svg class="w-6 h-6 text-white opacity-75 fill-current" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>edit</title><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></svg>
        <% end %>
      </div>
      <% end %>
    </div>
  <% end %>
</div>

If you look closely you'll see a new helper called author_of. I added this to extract logic from the view. That logic now lives inside app/helpers/application_helper.rb

# app/helpers/application_helper.rb

def author_of(resource)
  user_signed_in? && resource.user_id == current_user.id
end

I chose to put this in application_helper.rb because we'll be using it pretty much everywhere. The helper checks first if a user is signed in and also if the object passed through has a user_id attribute that matches the current_user id. This essentially says, only the logged-in user who created this project should be able to edit it. We also have an admin attribute by default on the User model. It's probably best to allow admins to edit this resource as well. We can create a new helper inside the app/helpers/application_helper.rb file as well.

# app/helpers/application_helper.rb

def author_of(resource)
  user_signed_in? && resource.user_id == current_user.id
end

def admin?
  user_signed_in? && current_user.admin?
end

Logged in authors get a new edit icon which links to an edit path. The UI now looks like this:

Extending Projects

Let's tackle the description field for our project model. Traditionally you would likely add a text data type to a new column in the database. With the official launch of Rails 6 we now have access to Action Text which is a nice rich text editor designed to work with Ruby on Rails apps and more. We can install action text dependencies using a simple command:

$ rails action_text:install

This installs some dependencies and creates two new migrations. Action Text uses Active Storage so that is installed as well.

Next we want to migrate those migration files in:

$ rails db:migrate  

These essentially create separate tables for action text fields and active storage fields. Ultimately, this means we don't need dedicated attributes on our other tables. Instead, we define what we need in our models and Rails handles the rest like magic.

Let's update the Project model:

# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :user
  has_rich_text :description
end

The line has_rich_text denotes a specific association for action text. You can name this whatever you please. I chose :description.

With that added, save the file and head to the form partial. Now we can add the new text area to the form:

<!-- app/views/projects/_form.html.erb-->

<%= form_with(model: project, local: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation" class="bg-white p-6 rounded text-red-500 mb-5">
      <h2 class="text-red-500 font-bold"><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :donation_goal, class: "label" %>
    <%= form.text_field :donation_goal, class: "input" %>
  </div>

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

  <div class="mb-6">
    <%= form.submit class: "btn btn-default" %>
  </div>
<% end %>

With this added we can check out the form. At the moment our CSS isn't loading correctly. It's also not matching our design. Instead of just using the defaults imported from the trix node package added during the installation I will customize the CSS completely.

Once Action Text gets installed it adds a actiontext.scss file to app/assets/stylesheets/. Our app's CSS will live in app/javascript/stylesheets. I'll search for the node module called `trix and import the CSS file from it.

/* app/javascript/stylesheets/actiontext.scss */

@import "trix/dist/trix.css";

trix-toolbar {
  .trix-button {
    @apply bg-white border-0;
  }

  .trix-button-group {
    border: 0;
  }

  .trix-button--icon-bold {
    @apply rounded-tl rounded-bl;
  }

  .trix-button--icon-redo {
    @apply rounded-tr rounded-br;
  }
}

.trix-button--icon-attach,
.trix-button-group-spacer,
.trix-button--icon-decrease-nesting-level,
.trix-button--icon-increase-nesting-level,
.trix-button--icon-code {
  display: none;
}

.trix-content {
  .attachment-gallery {
    > action-text-attachment,
    > .attachment {
      flex: 1 0 33%;
      padding: 0 0.5em;
      max-width: 33%;
    }

    &.attachment-gallery--2,
    &.attachment-gallery--4 {
      > action-text-attachment,
      > .attachment {
        flex-basis: 50%;
        max-width: 50%;
      }
    }
  }

  action-text-attachment {
    .attachment {
      padding: 0 !important;
      max-width: 100% !important;
    }
  }
}

We need to import it in our main application.scss file now:

/* app/javascript/stylesheets/application.scss */

@tailwind base;
@tailwind components;

// Custom SCSS
@import "components/buttons";
@import "components/forms";
@import "components/actiontext";
@import "components/typography";

@tailwind utilities;

While everything looks to be working, we have a couple more tasks to handle. On the controller level we need to permit this new field. Head to projects_controller.rb

At the bottom of the file you should see a private method called project_params

# app/controllers/projects_controller.rb
...
private
...

def project_params
  params.require(:project).permit(:title, :donation_goal)
end

We need to add :description to the permit method.

# app/controllers/projects_controller.rb
...
private
...

def project_params
  params.require(:project).permit(:title, :donation_goal, :description)
end

This tells rails to white-list that new field thus letting data get saved to the data base. We can update our index view to verify the output:

<!-- app/views/projects/index.html.erb-->
<div class="flex flex-wrap items-center justify-between">
  <% @projects.each do |project| %>
    <div class="border-2 border-blue-700 rounded-lg p-6 lg:w-1/4 w-full relative">
      <h3 class="font-bold text-2xl mb-2"><%= project.title %></h3>
      <div class="my-1"><%= truncate(strip_tags(project.description.to_s), length: 140) %></div>
      <!-- more code below omitted for brevity-->

Here we are displaying the project.description. Action Text comes back as HTML so we need a way to:

  1. Sanitize that data (strip tags, html, etc..)
  2. Truncate the newly sanitized data so it's not very long on the index view. You can set a length property to do this.

Adding Images

Let's add support for thumbnail images and logos for each project. We can also add gravatar support for each author of a project. We'll leverage active storage to add those thumbnails and a helper to add support for Gravatars.

Head to your project.rb model file. Within it I'll append the following:

# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :user
  has_rich_text :description
  has_one_attached :thumbnail # add this line
end

This new line hooks into Rails active storage. We signify we only want one attachment by the has_one prefix. You can also add has_many_attached to denote many attachments.

Next, I'll update the project form to include a file field as well as mark the form to accept multi-part data.

<%= form_with(model: project, local: true, multipart: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation" class="bg-white p-6 rounded text-red-500 mb-5">
      <h2 class="text-red-500 font-bold"><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :thumbnail, "Project thumbnail", class: "label" %>
    <%= form.file_field :thumbnail %>
  </div>

  <!-- code omitted for brevity -->

On the form options, I passed a multipart: true option. This tells the form we expect file data as a result. I also added the new file_field for the :thumbnail itself. We need to permit this field in our controller next.

Let's try attaching a thumbnail: https://unsplash.com/photos/cXkrqY2wFyc

If you update an existing project or add a new one you might see some gnarly logging. This means our work did indeed prove worthy:

 ActiveStorage::Blob Create (1.0ms)  INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "byte_size", "checksum", "created_at") VALUES (?, ?, ?, ?, ?, ?, ?)  [["key", "txcaum3pyrdvejhw0km0ul2wib99"], ["filename", "kelly-sikkema-cXkrqY2wFyc-unsplash.jpg"], ["content_type", "image/jpeg"], ["metadata", "{\"identified\":true}"], ["byte_size", 82684], ["checksum", "rJPItH/glusRYq0Q5+iOSg=="], ["created_at", "2019-08-31 13:28:19.543763"]]
  ↳ app/controllers/projects_controller.rb:36:in `block in update'
  ActiveStorage::Attachment Create (0.4ms)  INSERT INTO "active_storage_attachments" ("name", "record_type", "record_id", "blob_id", "created_at") VALUES (?, ?, ?, ?, ?)  [["name", "thumbnail"], ["record_type", "Project"], ["record_id", 1], ["blob_id", 1], ["created_at", "2019-08-31 13:28:19.546513"]]
  ↳ app/controllers/projects_controller.rb:36:in `block in update'
  Project Update (0.1ms)  UPDATE "projects" SET "updated_at" = ? WHERE "projects"."id" = ?  [["updated_at", "2019-08-31 13:28:19.547959"], ["id", 1]]
  ↳ app/controllers/projects_controller.rb:36:in `block in update'
   (3.0ms)  commit transaction
  ↳ app/controllers/projects_controller.rb:36:in `block in update'
  Disk Storage (1.3ms) Uploaded file to key: txcaum3pyrdvejhw0km0ul2wib99 (checksum: rJPItH/glusRYq0Q5+iOSg==)
[ActiveJob] Enqueued ActiveStorage::AnalyzeJob (Job ID: cdcf68f0-4d73-429c-9af5-7604645e8f40) to Sidekiq(active_storage_analysis) with arguments: #<GlobalID:0x00007fea5296a048 @uri=#<URI::GID gid://back-my-idea/ActiveStorage::Blob/1>>
Redirected to http://localhost:3000/projects/1

Heading back to our index we still can't see the image yet. Let's resolve that.

I'll add this to our existing card markup:

<%= image_tag project.thumbnail.variant(resize_to_limit: [600, 400]) %>

This still won't work unfortunately. Active storage needs another dependency to work with image varients on the fly like I'm doing here. We can resolve that with a simple gem install

We need to uncomment the image_processing gem in the Gemfile and run bundle install following that.

# Gemfile
# Use Active Storage variant
gem 'image_processing', '~> 1.2' # uncomment this line

Save that file and then run

$ bundle install

If your server is running, restart it.

Success!

Comments

Rather than scaffolding the complete project logic yet we can start of other aspects of the application. Comments are in virtually any user-facing app. We might as well add them to projects. We can do so in a scalable way using polymorphism. That essentially means we can add comments to anything if necessary.

Generate the comment model and resources:

$ rails g model Comment commentable_type:string commentable_id:integer user:references body:text

      invoke  active_record
      create    db/migrate/20200123193236_create_comments.rb
      create    app/models/comment.rb
      invoke    test_unit
      create      test/models/comment_test.rb
      create      test/fixtures/comments.yml

Adding this model creates a new migration essentially creating a comments table in the database. We can extend it to be polymorphic in the model layer and also do a has_many :through type of association. The result is as follows:

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true
end
# app/models/project.rb
class Project < ApplicationRecord
  belongs_to :user
  has_rich_text :description
  has_one_attached :thumbnail
  has_many :comments, as: :commentable
end

If we wanted to add comments to other models we totally could now thanks to how the association is set up and polymorphism.

Comments controller

To get our comment response cycle in order we need to add a comments_controller.rb to the app next.

$ rails g controller comments create 

This creates both a controller and create.html.erb view along with some other files of which you can discard if you like.

We also need an instance of commentable that makes sense to namespace within projects. We can do this by generating a new controller inside a folder called projects all inside app/controllers

$ rails g controller projects/comments

That file contains the following:

# app/controllers/projects/comments_controller.rb

class Projects::CommentsController < CommentsController
  before_action :set_commentable

  private

    def set_commentable
      @commentable = Project.find(params[:project_id])
    end
end

We are grabbing the instance of the project at hand and assigning it as @commentable in order to access it in the comments_controller.rb. You can repeat this concept for multiple resources if you have them. By this I mean you aren't bound to just Project resources. Notice how the class inherits from CommentsController directly. This is intended!

Inside the comments controller I've added the following code:

# app/controllers/comments_controller.rb

class CommentsController < ApplicationController
  before_action :authenticate_user!

  def create
    @comment = @commentable.comments.new comment_params
    @comment.user = current_user
    @comment.save
    redirect_to @commentable, notice: "Your comment was successfully posted."
  end

  private

    def comment_params
      params.require(:comment).permit(:body)
    end
end

We require the user to be signed in to comment on line 2. Inside the create action we create a @comment instance variable. It accesses the @commentable instance variable inherited from our controller within app/controllers/projects/comments_controller.rb file. We can create a new instance of that class and pass in the comment_params defined at the bottom of the file beneath the private declaration. Finally we assign the commenting user the current_user object and save. If all goes well we redirect back to the project or (@commentable) with a successful notice.

A bit of clean up

  1. I deleted the folder comments that was generated within app/views/projects
  2. A deleted the create.html.erb file inside app/views/comments
  3. I created two new partials within app/views/comments/ _comments.html.erb & _form.html.erb

In our project show view I've updated the markup a touch. Some dummy data is there which we'll address later. We want to add the comment feed for now:

<!-- app/views/projects/show.html.erb (WIP) -->
<div class="container relative p-6 mx-auto text-gray-900 bg-white rounded-lg lg:p-10">
  <div class="flex flex-wrap items-center justify-between w-full pb-4 mb-8 border-b-2 border-gray-200">
    <div class="flex flex-wrap items-start justify-between w-full pb-4 mb-4 border-b-2 border-gray-200">
      <div class="flex-1">
        <h1 class="text-3xl font-bold leading-none text-gray-800"><%= @project.title %></h1>
        <p class="text-sm italic text-gray-500">Created by <%= @project.user.name ||=
      @project.user.username %></p>
      </div>

      <% unless author_of(@project) %>
        <%= link_to "Back this idea", "#", class: "btn btn-default btn-lg lg:w-auto w-full lg:text-left text-center" %>
      <% end %>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%# @project.pledged_amount %>1000</p>
      <p class="text-sm text-gray-500">pledged of <%= number_to_currency(@project.donation_goal) %></p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">200</p>
      <p class="text-sm text-gray-500">backers</p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">20</p>
      <p class="text-sm text-gray-500">days to go</p>
    </div>
  </div>

  <div class="flex flex-wrap items-start justify-between mb-6">
    <div class="w-full lg:w-3/5">
     <% if @project.thumbnail.present? %>
       <%= image_tag @project.thumbnail, class: "rounded" %>
       <% else %>
      <div class="flex items-center justify-center p-8 text-center bg-gray-100 rounded">
        <div class="">
          <p class="text-xs font-bold text-gray-600 uppercase">PROJECT</p>
          <h3 class="text-2xl text-black"><%= @project.title %></h3>
        </div>
      </div>
    <% end %>
    </div>
    <div class="w-full mt-6 lg:pl-10 lg:w-2/5 lg:mt-0">
      <p class="text-sm font-semibold text-gray-500 uppercase">Description</p>
      <%= @project.description %>
    </div>
  </div>

  <div class="w-full lg:w-3/5">
    <%= render "comments/comments", commentable: @project  %>
    <%= render "comments/form", commentable: @project %>
  </div>

  <% if admin? || author_of(@project) %>
    <div class="absolute top-0 right-0 mt-4 mr-4">
      <%= link_to 'Edit', edit_project_path(@project), class: "btn btn-sm btn-outlined btn-default" %>
    </div>
  <% end %>
</div>

The comments partial (looping through each comment for display)

<!-- app/views/comments/_comments.html.erb -->
<p class="text-sm font-semibold text-gray-500 uppercase">Comments</p>
<% commentable.comments.each do |comment| %>
  <%= comment.body %>
<% end %>

The comment form

<!-- app/views/comments/_form.html.erb -->
<%= form_for [commentable, Comment.new] do |f| %>
  <div class="mb-6">
    <%= f.text_area :body, class: "input input-with-border", placeholder: "Add a comment", required: true %>
  </div>
  <%= f.submit class: "btn btn-default" %>
<% end %>

Comment routing

It likely makes sense to make our comments nested within projects no? We actually can do this quite easily in config/routes.rb

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

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

  resources :projects do
    resources :comments, module: :projects
  end

  devise_for :users
  root to: 'projects#index'
end

Within resources :projects I've added a block containing the new resources:comments line. We use the module: declaration to make projects not present in the url.

You'll likely want to restart your server at this point

$ CTRL + C
$ rails db:migrate
$ rails server

You should now be able to create and render comments all on the project show view. If you'd like to be able to edit or delete comments you can extend the comments controller to have update , edit and destroy actions. I'll be leaving this part out of this specific tutorial for now as I've covered it in other series.

Extending Projects further

With comments out of the way, we can direct our attention back to projects. You'll probably notice I don't have a few requirements mapped out in the data layer just yet. We still need the following:

  • A field to represent the current pledge amount. We'll use this to determine how much is needed to match the pledge goal at any given moment.
  • An expiration window for each project. Initially, this is capped at 30 days. We'll need a datetime stamp in the database for this
  • We need a way to count how many backers pledged each project. This can probably just be some logic we query for on the user layer. If a user backed an idea we can look up a charge relative to the project.
  • We need a status column to represent whether the project is active or past its pledge window of 30 days.

We can create a migration with those fields. We may add later if necessary.

$ rails g migration add_fields_to_projects current_donation_amount:integer expires_at:datetime status:string

That migration generates this file:

class AddFieldsToProjects < ActiveRecord::Migration[6.0]
  def change
    add_column :projects, :current_donation_amount, :integer, default: 0
    add_column :projects, :expires_at, :datetime, default: DateTime.now + 30.days
    add_column :projects, :status, :string, default: "active"
  end
end

Simple enough right?

My thought here is to update the current_donation_amount each time a backer "backs" a project. We'll default that column to 0 outright. We probably should have made the donation_goal column an integer as well but it's not a huge deal for now. We will have to convert the string into an integer coming up.

For expires_at we can create a default DateTime of 30 days each time a new project is created. I'll pass a default of the current time using default: DateTime.now.

The status of the project is either "active" or "inactive". Active meaning within the 30-day expiration and inactive when it's past that chunk of time.

$ rails db:migrate

Now each project has a status of "active", expiration start time,

Handling project expiration

There are a couple ways we can go about dynamically "expiring" a given project. Cron jobs and Active Jobs are probably the most common. Active Jobs seem more appealing to me in this case (mostly because I haven't done a lot of cron job work) but you are free to choose your weapon of choice here.

What I want to do is automatically change the status of an active project to "inactive" once the expires_at DateTime meets present day. Doing this manually seems ridiculous so we can build a job that will be enqueued every time a new project is created. First we need a job:

$ rails generate job ExpireProject
Running via Spring preloader in process 47618
      invoke  test_unit
      create    test/jobs/expire_project_job_test.rb
      create  app/jobs/expire_project_job.rb

Running that generation creates a couple files. The main one being the actual job class in mention:

# app/jobs/expire_project_job.rb
class ExpireProjectJob < ApplicationJob
  queue_as :default

  def perform(*args)
    # Do something later
  end
end

We'll add our logic within the perform method which is quite a simple change.

# app/jobs/expire_project_job.rb
class ExpireProjectJob < ApplicationJob
  queue_as :default

  def perform(project)
    @project = project

    return if project_already_inactive?

    @project.status = "inactive"
    @project.save!
  end

  private

  def project_already_inactive?
    @project.status == "inactive"
  end
end

We'll be passing in the project object directly when we initialize the Job. If the project already has a status of inactive we'll just return. If it's "active" we'll update the status to be "inactive" and save the object. It's probably a good idea to notify the author of the project that their project has expired. We can send a mailer to the author of the project just as well. That might look like the following:

# app/jobs/expire_project_job.rb
class ExpireProjectJob < ApplicationJob
  queue_as :default

  def perform(project)
    @project = project

    return if project_already_inactive?

    @project.status = "inactive"
    @project.save!

    UserMailer.with(project: @project).project_expired_notice.deliver_later
  end

  private

  def project_already_inactive?
    @project.status == "inactive"
  end
end

Let's make that mailer and method. We can do so from the command line (Have I told you how much I love Rails?)

$ rails g mailer User project_expired_notice

This generates a handful of files:

  • app/mailers/user_mailer.rb
  • app/views/user_mailer/project_expired_notice.html.erb
  • app/views/user_mailer/project_expired_notice.text.erb - which I've deleted
  • test/mailers/previews/user_mailer_preview.rb
  • test/mailers/user_mailer_test.rb

Let's look at the first file:

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def project_expired_notice
    @project = params[:project]

    mail to: @project.user.email, subject: "Your project has expired"
  end
end

Here I've added some logic to send the notice of expiration to the user who created the project. Notice how we use the instance variable as passed down from the original background job. This variable extends all the way down to the view layer!

<!-- app/views/user_mailer/project_expired_notice.html.erb -->
<h1>Hi <%= @project.user.name ||= @project.user.username %>,</h1>

<p>We wanted to inform you that the project <strong><%= @project.title %></strong> has met its expiration and is no longer active. <%= link_to "View your project", project_url(@project), target: "_blank" %>.</p>

Here we display a short message to the user and provide a call to action to view the project. Notice the URL helper is project_url instead of project_path. This is necessary for emails as we need absolute paths. People can check email from anywhere as you know.

We can then preview the email using the file test/mailers/previews/user_mailer_preview.rb and visiting localhost:3000/rails/mailers. You'll likely see an error because we technically don't have the right data we need. Here's the file as it was generated.

# test/mailers/previews/user_mailer_preview.rb

# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview

  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/project_expired_notice
  def project_expired_notice
    UserMailer.project_expired_notice
  end

end

And after:

# test/mailers/previews/user_mailer_preview.rb

class UserMailerPreview < ActionMailer::Preview
  def project_expired_notice
    UserMailer.with(project: Project.first).project_expired_notice
  end
end

I pass in the first Project in the database as a dummy project to view the mailer at http://localhost:3000/rails/mailers/user_mailer/project_expired_notice

The email doesn't look real hot but it does render and contains the link and project information we are after. Pretty cool!

With our email in place, we need to talk about background jobs for a bit.

Configuring background jobs

Running jobs usually occur in the background. If you're using my kickoff_tailwind template I already have my favorite background job configured tool called Sidekiq.

If you're not using my template then don't fear setting up sidekiq is pretty painless. The gem uses an adapter that hooks into ActiveJob which is already part of Rails by default.

Be sure to install the gem first!

If you peek inside config/application.rb you should see the main configuration you need to get started. If not go ahead and install the gem and copy the code below:

# config/application.rb
require_relative 'boot'

require 'rails/all'

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module BackMyIdea
  class Application < Rails::Application
    config.active_job.queue_adapter = :sidekiq
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 6.0

    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration can go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded after loading
    # the framework and any gems in your application.
  end
end

The main line we need is config.active_job.queue_adapter = :sidekiq.

My kickoff_tailwind template also adds some routing for Sidekiq as well. It's assumed only admins can see a GUI interface at all times. Here is my routes.rb file so far:

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

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

  resources :projects do
    resources :comments, module: :projects
  end

  devise_for :users
  root to: 'projects#index'
end

Testing it all out

We need to have sidekiq running in the background. You can open a new terminal instance alongside your rails server instance and type. Not you might need to install Redis as well (brew install redis).

$ bundle exec sidekiq -q default -q mailers

With that running, we can finally trigger the job within our projects controller.

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
...
 def create
    @project = Project.new(project_params)
    @project.user_id = current_user.id

    respond_to do |format|
      if @project.save
        ExpireProjectJob.set(wait_until: @project.expires_at).perform_later(@project)
        format.html { redirect_to @project, notice: 'Project was successfully created.' }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end
...
end

We added:

ExpireProjectJob.set(wait_until: @project.expires_at).perform_later(@project)

This calls out the job we created prior and specifically waits until the project expires_at column is met and then queues it up with Sidekiq.

Now you can create a new dummy project. Go ahead and do that now.

Now, I don't expect you to wait 30 days to see if this worked lol. Intead we can check our Rails logs for starters:

[ActiveJob] Enqueued ExpireProjectJob (Job ID: 1fcc879f-139a-40c0-bf72-7e5b3dc1f0ef) to Sidekiq(default) with arguments: #<GlobalID:0x00007fd18f040700 @uri=#<URI::GID gid://back-my-idea/Project/3>>
Redirected to http://localhost:3000/projects/3

Good, the job gets enqueued successfully but we don't know for sure if the code within the job is going to work as well. One way to gut-check it is to modify the initial wait time of the job to nothing. Let's try that:

ExpireProjectJob.perform_now(@project)

Note you can also do this from rails console if you like. You'd need to create a project manually though.

Create another project using the UI and see what happens.

I don't have a way to see if the project is inactive or active except within rails console at this point so a quick way to check is:

$ rails c
Project.last
=> <Project id: 2, title: "test", donation_goal: 0.22222e5, user_id: 1, created_at: "2020-01-24 21:17:27", updated_at: "2020-01-24 21:17:27", current_donation_amount: 0, expires_at: "2020-02-22 22:51:44", status: "inactive">
irb(main):003:0>

Success! The status is inactive. Remember we set this column to be active by default when a new project is created.

Now we can modify the views to correlate with the status. It's a pretty easy conditional of which I'll extract into partials and a few helper methods:

# app/models/project.rb

class Project < ApplicationRecord
...
  def active?
    status == "active"
  end

  def inactive
    status == "inactive"
  end
 end

And the main show view:

<!-- app/views/projects/show.html.erb -->
<% if @project.active? %>
  <%= render "active_project", project: @project %>
<% else %>
  <%= render "inactive_project", project: @project %>
<% end %>

The active project view partial (has since been update a touch with stats):

<div class="container relative p-6 mx-auto text-gray-900 bg-white rounded-lg lg:p-10">
  <div class="flex flex-wrap items-center justify-between w-full pb-4 mb-8 border-b-2 border-gray-200">
    <div class="flex flex-wrap items-start justify-between w-full pb-4 mb-4 border-b-2 border-gray-200">
      <div class="flex-1">
        <h1 class="text-3xl font-bold leading-none text-gray-800"><%= project.title %></h1>
        <p class="text-sm italic text-gray-500">Created by <%= project.user.name ||=
      project.user.username %></p>
      </div>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%= number_to_currency(project.current_donation_amount) %>/mo</p>
      <p class="text-sm text-gray-500">pledged of <%= number_to_currency(project.donation_goal) %>/mo</p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">200</p>
      <p class="text-sm text-gray-500">backers</p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%= distance_of_time_in_words_to_now(project.expires_at) %></p>
      <p class="text-sm text-gray-500">to go</p>
    </div>
  </div>

  <div class="flex flex-wrap items-start justify-between mb-6">
    <div class="w-full lg:w-3/5">
    <% if project.thumbnail.present? %>
      <%= image_tag project.thumbnail, class: "rounded" %>
    <% else %>
      <div class="flex items-center justify-center p-8 text-center bg-gray-100 rounded">
        <div class="">
          <p class="text-xs font-bold text-gray-600 uppercase">PROJECT</p>
          <h3 class="text-2xl text-black"><%= project.title %></h3>
        </div>
      </div>
    <% end %>
    </div>
    <div class="w-full mt-6 lg:pl-10 lg:w-2/5 lg:mt-0">
      <p class="text-sm font-semibold text-gray-500 uppercase">Description</p>
      <%= project.description %>
    </div>
  </div>

  <div class="w-full lg:w-3/5">
    <%= render "comments/comments", commentable: project  %>
    <%= render "comments/form", commentable: project %>
  </div>

 <% if admin? || author_of(project) %>
    <div class="absolute top-0 right-0 mt-4 mr-4">
      <%= link_to 'Edit', edit_project_path(project), class: "btn btn-sm btn-outlined btn-default" %>
      <%= link_to 'Delee', project_path(project), method: :delete, class: "btn btn-sm btn-outlined btn-default", data: { confirm: "Are you sure?" } %>
    </div>
  <% end %>
</div>


And finally the inactive view with only subtle changes:

<div class="container relative p-6 mx-auto text-gray-900 bg-white border-2 border-red-500 rounded-lg lg:p-10">
  <div class="flex flex-wrap items-center justify-between w-full pb-4 mb-8 border-b-2 border-gray-200">
    <div class="flex flex-wrap items-start justify-between w-full pb-4 mb-4 border-b-2 border-gray-200">
      <div class="flex-1">
        <span class="px-2 py-1 text-xs font-semibold text-white bg-red-500 rounded-lg">Inactive</span>

        <h1 class="mt-4 text-3xl font-bold leading-none text-gray-800"><%= project.title %></h1>
        <p class="text-sm italic text-gray-500">Created by <%= project.user.name ||=
      project.user.username %></p>
      </div>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%= number_to_currency(project.current_donation_amount) %></p>
      <p class="text-sm text-gray-500">pledged of <%= number_to_currency(project.donation_goal) %></p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">200</p>
      <p class="text-sm text-gray-500">backers</p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">Expired</p>
      <p class="text-sm text-gray-500"><%= time_ago_in_words(@project.expires_at) %> ago</p>
    </div>
  </div>

  <div class="flex flex-wrap items-start justify-between mb-6">
    <div class="w-full lg:w-3/5">
    <% if project.thumbnail.present? %>
      <%= image_tag project.thumbnail, class: "rounded" %>
    <% else %>
      <div class="flex items-center justify-center p-8 text-center bg-gray-100 rounded">
        <div class="">
          <p class="text-xs font-bold text-gray-600 uppercase">PROJECT</p>
          <h3 class="text-2xl text-black"><%= project.title %></h3>
        </div>
      </div>
    <% end %>
    </div>
    <div class="w-full mt-6 lg:pl-10 lg:w-2/5 lg:mt-0">
      <p class="text-sm font-semibold text-gray-500 uppercase">Description</p>
      <%= project.description %>
    </div>
  </div>

  <div class="w-full lg:w-3/5">
    <%= render "comments/comments", commentable: project  %>
    <p>Comments are closed for inactive projects</p>
  </div>

  <% if admin? || author_of(project) %>
    <div class="absolute top-0 right-0 mt-4 mr-4">
      <%= link_to 'Edit', edit_project_path(project), class: "btn btn-sm btn-outlined btn-default" %>
      <%= link_to 'Delee', project_path(project), method: :delete, class: "btn btn-sm btn-outlined btn-default", data: { confirm: "Are you sure?" } %>
    </div>
  <% end %>
</div>

If you head back to the projects index we still see all projects in view no matter what their status. It probably makes sense to only feed active projects through. We can add a scope for this in the model.

# app/models/project.rb

class Project < ApplicationRecord
  ...
  scope :active, ->{ where(status: "active") }
  scope :inactive, ->{ where(status: "inactive") }
  ...
 end

Then in our controller update the index action to scope through the active scope.

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  before_action :set_project, only: [:show, :edit, :update, :destroy]
  before_action :authenticate_user!, except: [:index, :show]

  def index
    @projects = Project.active # swap from all to active
  end
  ...
end

Great now only "active" projects show up in our feed!

Payment Setup with Stripe

Stripe connect oAuth Strategy

We need a couple of gems to make our lives easier working with Stripe. We'll specifically be using Stripe Connect which is a way to be a platform in between all user payment transactions. The platform can earn a percentage per sale which makes marketplaces what they are!

# Gemfile

gem 'stripe'
gem 'omniauth', '~> 1.9'
gem 'omniauth-stripe-connect'

Install those:

$ bundle install

Leveraging the omniauth-stripe-connect gem we can easily hook into Devise with a little configuration. (If you're using my template, Devise is already installed and configured. If not, you will need to install it yourself to get up to speed here).

More Modeling

Update the User model

We need to store some Stripe information for each user who decides to start a project. Doing so means a few new fields in the database.

$ rails g migration add_stripe_fields_to_users uid:string provider:string access_code:string publishable_key:string

These fields should be enough to create stripe accounts via stripe connect and start processing transactions once a user has authenticated via OAuth

Here's the migration. We should be good to migrate! You'll see where we use these fields coming up.

# db/migrate/XXXX_add_stripe_fields_to_users.rb
class AddStripeFieldsToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :uid, :string
    add_column :users, :provider, :string
    add_column :users, :access_code, :string
    add_column :users, :publishable_key, :string
  end
end
$ rails db:migrate

oAuth Flow

To connect with Stripe connect we need a way for both merchants and customers to distinguish themselves. Each user will need a corresponding stripe customer id that comes back during the oAuth redirection back to our app. We can hook into a handy gem that works great with Devise to help with this.

The gem is called omniauth-stripe-connect which you should have installed in a previous step.

Declare a providor - Stripe Connect

To get the gem integrated with Devise we focus within config/initializers/devise.rb. We need to declare the new provider(you can have many i.e. Twitter, Facebook, Google, etc...). There will be tons of comments and settings within that file. I added the following at the end.

# config/initializers/devise.rb
Devise.setup do |config|
  ...
  config.omniauth :stripe_connect, 
  Rails.application.credentials.dig(:stripe, :connect_client_id),             Rails.application.credentials.dig(:stripe, :private_key),
  scope: 'read_write',
  stripe_landing: 'login'
end

This file points to keys we haven't added to our app just yet. They can be called whatever you like.

To add the keys you'll need to grab them from your Stripe account. For testing purposes, I recommend setting up a new testing account altogether. You should be able to find your connect client_id key within dashboard.stripe.com/account/applications/settings .

Adding test keys to your app

If you're new to Rails, encrypted credentials it's worth googling a bit to understand why and how they work the way they do. Why this topic isn't in the main Rails documentation is beside me. ‍♂️ Maybe I'll create a pull request to add it.

At its simplest form Rails 5.2+ comes with a command we can run to decrypt/generate credential files that are YAML files.

$ rails credentials:edit

Running this command will probably throw an error like:

No $EDITOR to open file in. Assign one like this:

EDITOR="mate --wait" bin/rails credentials:edit

For editors that fork and exit immediately, it's important to pass a wait flag,
otherwise, the credentials will be saved immediately with no chance to edit.

This means we need to pass a code editor that will allow us to open a decrypted file that gets generated. I'm using VS code so I'll pass the following:

$ EDITOR="code --wait" rails credentials:edit

This should open a new window in Visual Studio Code with some YAML code within. Here's where you can add your keys. I believe you can add export EDITOR="code --wait to your .bash_profile or .zshrc file to make this happen automatically in the future.

The file that opens is considered your "production" credentials file. Meaning if you ship the code to a live server, Rails will look here assuming it's in production mode. If you'd rather generate separate development and production keys you totally can. In fact, I'll do just that for this tutorial. To do this you provide the proper environment you're after with a flag.

$ EDITOR="code --wait" rails credentials:edit --environment=development

You can pass whatever environment you like there. Passing no environment means it's the production environment by default.

I'll pass the following:

$ EDITOR="code --wait" rails credentials:edit --environment=development

Inside the file that opens you'll likely see some dummy yaml:

# aws:
#   access_key_id: 123
#   secret_access_key: 345

We'll follow the same formatting here and add our Stripe credentials (the test ones, not the live ones). Grab those from your account and put them here.

stripe:
  connect_client_id: ca_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  publishable_key: pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  private_key: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

As of 2020:

With those keys saved, closing the file will encrypt the code so it's much more secure. When you create credential YAML files a master.key file is created of which you'll need to share with only those you trust. They can use the key to be able to decrypt the initial credentials file. This applies to each environment. So in our case, we have a development.keythat was generated within config/credentials.

This all seems complicated but I finally got used to it after a few uses.

Configuring the Stripe gem

We installed the Stripe gem but haven't really authorized it yet. Since we just added our credentials it's an easy addition. You'll create a new initializer called stripe.rb within config/initializers/ next.

# config/initializers/stripe.rb
Rails.configuration.stripe = {
  :publishable_key => Rails.application.credentials.dig(:stripe, :public_key),
  :secret_key => Rails.application.credentials.dig(:stripe, :private_key)
}
Stripe.api_key = Rails.application.credentials.dig(:stripe, :private_key)

Making the User model omniauthable

Luckily for us Devise has a plan for omniauth integration already in place. We just need to declare the provider we added in the previous step like so.

# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :omniauthable, omniauth_providers: [:stripe_connect]
  has_many :projects, dependent: :destroy
end

Customizing the omniauth callback

To add the necessary routing and controllers for Stripe to connect successfully to our app we need to provide a game plan with a new controller and route option.

Starting with the routes we extend our existing devise_for method by declaring a new controller for the omniauth_callback controller explicitly.

# config/routes.rb
# change this `devise_for` line to the following

devise_for :users, controllers: { omniauth_callbacks: "omniauth_callbacks" }

Here we tell Devise what controller to expect. Doing this means we need to create a new controller named omniauth_callbacks_controller.rb within app/controllers

Based on the request we can pluck parameters out and update attributes on a given user's account. Right now we need to define what those are to make Stripe Connect function. We added some fields you might see below previously on the users database table. Here is where they come into play.

# app/controllers/omniauth_callbacks_controller.rb
class OmniauthCallbacksController < Devise::OmniauthCallbacksController

  def stripe_connect
    auth_data = request.env["omniauth.auth"]
    @user = current_user
    if @user.persisted?
      @user.provider = auth_data.provider
      @user.uid = auth_data.uid
      @user.access_code = auth_data.credentials.token
      @user.publishable_key = auth_data.info.stripe_publishable_key
      @user.save

      sign_in_and_redirect @user, event: :authentication
      flash[:notice] = 'Stripe Account Created And Connected' if is_navigational_format?
    else
      session["devise.stripe_connect_data"] = request.env["omniauth.auth"]
      redirect_to root_path
    end
  end

  def failure
    redirect_to root_path
  end
end

This takes care of the request logic but we still need a place to point users who want to authenticate with Stripe Connect initially. Doing so means defining a dynamic URL based on a few parameters about our own Stripe Connect account. I'll add a new helper to encapsulate this logic:

# app/helpers/application_helper.rb
module ApplicationHelper
  def stripe_url
    "https://connect.stripe.com/oauth/authorize?response_type=code&client_id=#{Rails.application.credentials.dig(:stripe, :connect_client_id)}&scope=read_write"
  end
end

and another to check if a user can receive payments altogether we will add to the User model:

class User < ApplicationRecord
  ...
def can_receive_payments?
  uid? && provider? && access_code? && publishable_key?
end

end

This checks for each field's presence as outlined by their names in the database.

Adding the view layer

With this logic in place, we can start to mold how the flow of oAuth-ing users with Stripe Connect will take place.

To make our lives easier in the view layer I added a couple of helpers to extract some logic out:

module ApplicationHelper
  ...
  def stripe_url
    "https://connect.stripe.com/oauth/authorize?response_type=code&client_id=#{Rails.application.credentials.dig(:stripe, :connect_client_id)}&scope=read_write"
  end

  def stripe_connect_button # add this method
    link_to stripe_url, class: "btn-stripe-connect" do
      content_tag :span, "Connect with Stripe"
    end
  end
end

I used the view within the devise views folder called registrations/edit.html.erb as a home for the Stripe authentication button. The UI here is lackluster but it's enough for you to build upon.

<!-- app/views/devise/registration/edit.html.erb -->
<div class="container flex flex-wrap items-start justify-between mx-auto">
  <div class="w-full lg:w-1/2">
  <h2 class="pt-4 mb-8 text-4xl font-bold heading">Account</h2>

  <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>

    <%= render "devise/shared/error_messages", resource: resource %>

    <div class="mb-6">
      <%= f.label :username, class:"label" %>
      <%= f.text_field :username, autofocus: true, class:"input" %>
    </div>

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

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

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

    <div class="mb-6">
      <%= f.label :password, class:"label" %>
      <%= f.password_field :password, autocomplete: "new-password", class:"input" %>
      <p class="pt-1 text-sm italic text-grey-dark"> <% 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" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input" %>
    </div>

    <div class="mb-6">
      <%= f.label :current_password, class: "label" %>
      <%= f.password_field :current_password, autocomplete: "current-password", class: "input" %>
      <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: "btn btn-default" %>
    </div>
    <% end %>

    <hr class="mt-6 mb-3 border" />

    <h3 class="mb-4 text-xl font-bold heading">Cancel my account</h3>

    <div class="flex items-center justify-between">
      <div class="flex-1"><p class="py-4">Unhappy?</p></div>

      <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete, class: "btn btn-default" %>
    </div>
  </div>

<div class="w-full text-left lg:pl-16 lg:w-1/2">
  <div class="p-6 mt-10 text-gray-900 bg-white rounded shadow-lg">
    <% unless resource.can_receive_payments? %>
      <h4 class="mb-6 text-xl font-semibold leading-none text-gray-900">You wont be able to sell items until you register with Stripe!</h4>
      <%= stripe_button %>
    <% else %>
      <h4 class="mb-6 text-xl font-semibold leading-none text-gray-900">Successfully connected to Stripe  ✅</h4>
    <% end %>
  </div>
</div>

I normally used a content_for block here within my template but decided to customize this view a bit. At the bottom is the main area to pay attention to. Here we add a conditional around if a user already has connected with Stripe or not. If so we display a primitive success message.

To maintain branding I've added a Stripe-styled button that users of Stripe might identify with more than a generic alternative. This gives a bit more sense of trust in terms of user experience. Doing this requires some CSS of which I've added to our _buttons.scss partial (part of my kickoff_tailwind) template.

/* app/javascript/stylesheets/components/_buttons.scss */
.btn-stripe-connect {
  display: inline-block;
  margin-bottom: 1px;
  background-image: -webkit-linear-gradient(#28A0E5, #015E94);
  background-image: -moz-linear-gradient(#28A0E5, #015E94);
  background-image: -ms-linear-gradient(#28A0E5, #015E94);
  background-image: linear-gradient(#28A0E5, #015E94);
  -webkit-font-smoothing: antialiased;
  border: 0;
  padding: 1px;
  height: 30px;
  text-decoration: none;
  -moz-border-radius: 4px;
  -webkit-border-radius: 4px;
  border-radius: 4px;
  -moz-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
  -webkit-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
  cursor: pointer;
  -moz-user-select: none;
  -webkit-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
.btn-stripe-connect span {
  display: block;
  position: relative;
  padding: 0 12px 0 44px;
  height: 30px;
  background: #1275FF;
  background-image: -webkit-linear-gradient(#7DC5EE, #008CDD 85%, #30A2E4);
  background-image: -moz-linear-gradient(#7DC5EE, #008CDD 85%, #30A2E4);
  background-image: -ms-linear-gradient(#7DC5EE, #008CDD 85%, #30A2E4);
  background-image: linear-gradient(#7DC5EE, #008CDD 85%, #30A2E4);
  font-size: 14px;
  line-height: 30px;
  color: white;
  font-weight: bold;
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);
  -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
  -moz-border-radius: 3px;
  -webkit-border-radius: 3px;
  border-radius: 3px;
}
.btn-stripe-connect span:before {
  content: '';
  display: block;
  position: absolute;
  left: 11px;
  top: 50%;
  width: 23px;
  height: 24px;
  margin-top: -12px;
  background-repeat: no-repeat;
  background-size: 23px 24px;
}
.btn-stripe-connect:active {
  background: #005D93;
}
.btn-stripe-connect:active span {
  color: #EEE;
  background: #008CDD;
  background-image: -webkit-linear-gradient(#008CDD, #008CDD 85%, #239ADF);
  background-image: -moz-linear-gradient(#008CDD, #008CDD 85%, #239ADF);
  background-image: -ms-linear-gradient(#008CDD, #008CDD 85%, #239ADF);
  background-image: linear-gradient(#008CDD, #008CDD 85%, #239ADF);
  -moz-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.1);
  -webkit-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.1);
  box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.1);
}
.btn-stripe-connect:active span:before {} .btn-stripe-connect.light-blue {
  background: #b5c3d8;
  background-image: -webkit-linear-gradient(#b5c3d8, #9cabc2);
  background-image: -moz-linear-gradient(#b5c3d8, #9cabc2);
  background-image: -ms-linear-gradient(#b5c3d8, #9cabc2);
  background-image: linear-gradient(#b5c3d8, #9cabc2);
  -moz-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
  -webkit-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
}
.btn-stripe-connect.light-blue span {
  color: #556F88;
  text-shadow: 0 1px rgba(255, 255, 255, 0.8);
  background: #f0f5fa;
  background-image: -webkit-linear-gradient(#f0f5fa, #e4ecf5 85%, #e7eef6);
  background-image: -moz-linear-gradient(#f0f5fa, #e4ecf5 85%, #e7eef6);
  background-image: -ms-linear-gradient(#f0f5fa, #e4ecf5 85%, #e7eef6);
  background-image: linear-gradient(#f0f5fa, #e4ecf5 85%, #e7eef6);
  -moz-box-shadow: inset 0 1px 0 #fff;
  -webkit-box-shadow: inset 0 1px 0 #fff;
  box-shadow: inset 0 1px 0 #fff;
}
.btn-stripe-connect.light-blue:active {
  background: #9babc2;
}
.btn-stripe-connect.light-blue:active span {
  color: #556F88;
  text-shadow: 0 1px rgba(255, 255, 255, 0.8);
  background: #d7dee8;
  background-image: -webkit-linear-gradient(#d7dee8, #e7eef6);
  background-image: -moz-linear-gradient(#d7dee8, #e7eef6);
  background-image: -ms-linear-gradient(#d7dee8, #e7eef6);
  background-image: linear-gradient(#d7dee8, #e7eef6);
  -moz-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.05);
  -webkit-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.05);
  box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.05);
}
.btn-stripe-connect.dark {
  background: #252525;
  background: rgba(0, 0, 0, 0.5) !important;
}
/* Images*/

.btn-stripe-connect span:before,
.btn-stripe-connect.blue span:before {
  background-image: url("data:image/png;base64,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");
}
.btn-stripe-connect.light-blue span:before {
  background-image: url("data:image/png;base64,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");
}
/* Retina support */

@media only screen and (-webkit-min-device-pixel-ratio: 1.5),
only screen and (min--moz-device-pixel-ratio: 1.5),
only screen and (min-device-pixel-ratio: 1.5) {
  .btn-stripe-connect span:before,
  .btn-stripe-connect.blue span:before {
    background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAwCAYAAABuZUjcAAAKRGlDQ1BJQ0MgUHJvZmlsZQAASA2dlndUFNcXx9/MbC+0XZYiZem9twWkLr1IlSYKy+4CS1nWZRewN0QFIoqICFYkKGLAaCgSK6JYCAgW7AEJIkoMRhEVlczGHPX3Oyf5/U7eH3c+8333nnfn3vvOGQAoASECYQ6sAEC2UCKO9PdmxsUnMPG9AAZEgAM2AHC4uaLQKL9ogK5AXzYzF3WS8V8LAuD1LYBaAK5bBIQzmX/p/+9DkSsSSwCAwtEAOx4/l4tyIcpZ+RKRTJ9EmZ6SKWMYI2MxmiDKqjJO+8Tmf/p8Yk8Z87KFPNRHlrOIl82TcRfKG/OkfJSREJSL8gT8fJRvoKyfJc0WoPwGZXo2n5MLAIYi0yV8bjrK1ihTxNGRbJTnAkCgpH3FKV+xhF+A5gkAO0e0RCxIS5cwjbkmTBtnZxYzgJ+fxZdILMI53EyOmMdk52SLOMIlAHz6ZlkUUJLVlokW2dHG2dHRwtYSLf/n9Y+bn73+GWS9/eTxMuLPnkGMni/al9gvWk4tAKwptDZbvmgpOwFoWw+A6t0vmv4+AOQLAWjt++p7GLJ5SZdIRC5WVvn5+ZYCPtdSVtDP6386fPb8e/jqPEvZeZ9rx/Thp3KkWRKmrKjcnKwcqZiZK+Jw+UyL/x7ifx34VVpf5WEeyU/li/lC9KgYdMoEwjS03UKeQCLIETIFwr/r8L8M+yoHGX6aaxRodR8BPckSKPTRAfJrD8DQyABJ3IPuQJ/7FkKMAbKbF6s99mnuUUb3/7T/YeAy9BXOFaQxZTI7MprJlYrzZIzeCZnBAhKQB3SgBrSAHjAGFsAWOAFX4Al8QRAIA9EgHiwCXJAOsoEY5IPlYA0oAiVgC9gOqsFeUAcaQBM4BtrASXAOXARXwTVwE9wDQ2AUPAOT4DWYgSAID1EhGqQGaUMGkBlkC7Egd8gXCoEioXgoGUqDhJAUWg6tg0qgcqga2g81QN9DJ6Bz0GWoH7oDDUPj0O/QOxiBKTAd1oQNYSuYBXvBwXA0vBBOgxfDS+FCeDNcBdfCR+BW+Bx8Fb4JD8HP4CkEIGSEgeggFggLYSNhSAKSioiRlUgxUonUIk1IB9KNXEeGkAnkLQaHoWGYGAuMKyYAMx/DxSzGrMSUYqoxhzCtmC7MdcwwZhLzEUvFamDNsC7YQGwcNg2bjy3CVmLrsS3YC9ib2FHsaxwOx8AZ4ZxwAbh4XAZuGa4UtxvXjDuL68eN4KbweLwa3gzvhg/Dc/ASfBF+J/4I/gx+AD+Kf0MgE7QJtgQ/QgJBSFhLqCQcJpwmDBDGCDNEBaIB0YUYRuQRlxDLiHXEDmIfcZQ4Q1IkGZHcSNGkDNIaUhWpiXSBdJ/0kkwm65KdyRFkAXk1uYp8lHyJPEx+S1GimFLYlESKlLKZcpBylnKH8pJKpRpSPakJVAl1M7WBep76kPpGjiZnKRcox5NbJVcj1yo3IPdcnihvIO8lv0h+qXyl/HH5PvkJBaKCoQJbgaOwUqFG4YTCoMKUIk3RRjFMMVuxVPGw4mXFJ0p4JUMlXyWeUqHSAaXzSiM0hKZHY9O4tHW0OtoF2igdRzeiB9Iz6CX07+i99EllJWV75RjlAuUa5VPKQwyEYcgIZGQxyhjHGLcY71Q0VbxU+CqbVJpUBlSmVeeoeqryVYtVm1Vvqr5TY6r5qmWqbVVrU3ugjlE3VY9Qz1ffo35BfWIOfY7rHO6c4jnH5tzVgDVMNSI1lmkc0OjRmNLU0vTXFGnu1DyvOaHF0PLUytCq0DqtNa5N03bXFmhXaJ/RfspUZnoxs5hVzC7mpI6GToCOVGe/Tq/OjK6R7nzdtbrNug/0SHosvVS9Cr1OvUl9bf1Q/eX6jfp3DYgGLIN0gx0G3QbThkaGsYYbDNsMnxipGgUaLTVqNLpvTDX2MF5sXGt8wwRnwjLJNNltcs0UNnUwTTetMe0zg80czQRmu836zbHmzuZC81rzQQuKhZdFnkWjxbAlwzLEcq1lm+VzK32rBKutVt1WH60drLOs66zv2SjZBNmstemw+d3W1JZrW2N7w45q52e3yq7d7oW9mT3ffo/9bQeaQ6jDBodOhw+OTo5ixybHcSd9p2SnXU6DLDornFXKuuSMdfZ2XuV80vmti6OLxOWYy2+uFq6Zroddn8w1msufWzd3xE3XjeO2323Ineme7L7PfchDx4PjUevxyFPPk+dZ7znmZeKV4XXE67m3tbfYu8V7mu3CXsE+64P4+PsU+/T6KvnO9632fein65fm1+g36e/gv8z/bAA2IDhga8BgoGYgN7AhcDLIKWhFUFcwJTgquDr4UYhpiDikIxQODQrdFnp/nsE84by2MBAWGLYt7EG4Ufji8B8jcBHhETURjyNtIpdHdkfRopKiDke9jvaOLou+N994vnR+Z4x8TGJMQ8x0rE9seexQnFXcirir8erxgvj2BHxCTEJ9wtQC3wXbF4wmOiQWJd5aaLSwYOHlReqLshadSpJP4iQdT8YmxyYfTn7PCePUcqZSAlN2pUxy2dwd3Gc8T14Fb5zvxi/nj6W6pZanPklzS9uWNp7ukV6ZPiFgC6oFLzICMvZmTGeGZR7MnM2KzWrOJmQnZ58QKgkzhV05WjkFOf0iM1GRaGixy+LtiyfFweL6XCh3YW67hI7+TPVIjaXrpcN57nk1eW/yY/KPFygWCAt6lpgu2bRkbKnf0m+XYZZxl3Uu11m+ZvnwCq8V+1dCK1NWdq7SW1W4anS1/+pDa0hrMtf8tNZ6bfnaV+ti13UUahauLhxZ77++sUiuSFw0uMF1w96NmI2Cjb2b7Dbt3PSxmFd8pcS6pLLkfSm39Mo3Nt9UfTO7OXVzb5lj2Z4tuC3CLbe2emw9VK5YvrR8ZFvottYKZkVxxavtSdsvV9pX7t1B2iHdMVQVUtW+U3/nlp3vq9Orb9Z41zTv0ti1adf0bt7ugT2ee5r2au4t2ftun2Df7f3++1trDWsrD+AO5B14XBdT1/0t69uGevX6kvoPB4UHhw5FHupqcGpoOKxxuKwRbpQ2jh9JPHLtO5/v2pssmvY3M5pLjoKj0qNPv0/+/tax4GOdx1nHm34w+GFXC62luBVqXdI62ZbeNtQe395/IuhEZ4drR8uPlj8ePKlzsuaU8qmy06TThadnzyw9M3VWdHbiXNq5kc6kznvn487f6Iro6r0QfOHSRb+L57u9us9ccrt08rLL5RNXWFfarjpebe1x6Gn5yeGnll7H3tY+p772a87XOvrn9p8e8Bg4d93n+sUbgTeu3px3s//W/Fu3BxMHh27zbj+5k3Xnxd28uzP3Vt/H3i9+oPCg8qHGw9qfTX5uHnIcOjXsM9zzKOrRvRHuyLNfcn95P1r4mPq4ckx7rOGJ7ZOT437j154ueDr6TPRsZqLoV8Vfdz03fv7Db56/9UzGTY6+EL+Y/b30pdrLg6/sX3VOhU89fJ39ema6+I3am0NvWW+738W+G5vJf49/X/XB5EPHx+CP92ezZ2f/AAOY8/wRDtFgAAAIbklEQVRoBdVZa5BURxU+fZ9z57mzs7PvF4i7srAQSCifMVDERC0jYlzUlJalKeGPlCnL/NEfywpWacoiVZRVJIYfGjGUu5bxj5qHFSAYyQOBEsJzYSHDvnd2dp535j66PX1vNgsULDPs1cr2Vs+9e7v79NfnnnP663MJYwwWYxEWI2iOedEClxabxgkBwjEvOuA9PQOOlSw64JMr4vK8GidYYMcOES4tVSEAAZ8FAUqon1GiAJEEEG0CjFB8cTaxZUMAo1gEqQA0UABprAjPbrUwXnkesgqKP8CBk5vDIenrE+BKmwI+MawA1MbCkdV10cBDflXuVmSxQRbFkCAQZ9U2ZTaONyxKcyXDHjMs83ImV3rz6njmDRPMUZB80zAJOuvvsflkXpTP7DrWyeXcYCqk75AEieawrEoty1vrvlcV0ja3VQdb1rVUQVd9EFqqNIj5ZfDJooPBsCnohq2ldDMynC42XZnW7z09lu25lMxDMl34y0gyvTsBwyewc84Z4MEPpWIzF/MBcLLtNzJISmxZU+PmWETbtqGzfvVja5uguyF02+kCIEJUk6Ex4oMV9XP9ZnQT/nZ24it7XrtoJ5LZ7SjAM+Bg2+0ckAOcbBkQIaZFVzY1bGurjezYfn87PNQZ5+13ZaQRXMzH26Lg8ymfUokQdAR59INOc53GQ6q/Jiiua6oJ7+h9uAPua47cHeLrwHEmQRmTGLHV6x4v+JYwWsOFCGRDn6RKem1rPPrkN9Y0uqAXLN4VwCgjYGEE8rBgMAjwKsF9S9WgLa9qjYcf+Po9jXdlGrfC5Wj8Vg0Lf+ZENAFmpGB9TWTLhmUxUD1UDg/gtudRnK+a4RtkgqQyO+RT5LVrmiLgJcN19gcGNojUWriS5yRQm7pcBTc/vyCKdW1RrWwzOTiYhGf+dRUmcgZosgDVfgWaMCS2V2tO+OzG0MiVjdUwiFiYm9a7O4kJAoZEooV9H4T0O0ofODkKr5+6+nY6V3heVZQpv6ZWaz55qSJJnXjtUBW5pT7k8xeK5u+B0PQdBVbQgTLq9HbQYthyNVSmTT6A/nB0aGpF0K99+trY1F7TNI9PZGXkKUVRtYjGZCIOV1dHR4Ynz8FSLV8BrjK6uiAlpLcmco1ipmgpAaU8rfesboCuumBg31uJbx6+qH0uX9D/em0i85xFhaslKZKA8/82RtYDhd/1MkCuBnjxrLgKB0EQSb5oWO+9O1bZrsy3+Kc3dcH+b99b07NuyXe6P9r8z/am+C9lkuqCjo4qGGkQES76qJcuz/2GOlUoFuVsQS+98frlaSeq8Gkqqctrg7Dz853wwrfugUfXtj3W3tJ8oCletRUEXy1SCSSYHhdu41gFqILcZCrzwkvnJmE0U3JtHefiL7eS2l7th11f7IQ9j65aVh+r+nlzbd2TELJrHPLmIXZX3wyBX8MTQMm8PJ0u9Pe9chGQYy9omvXouHu/thJqI+Ef1sZDm0AMBmfPiQsSPDuY2zhWwSH5ISU5Pjm98x9nRo7+7JVBB3wl5nJz35Vo/z/esBQUVf2+QlkD9Aw42/Ts3Au7ushdAhQ5UzJoOjE+OrV9/1tDR7cNnIax7N2bDX9nm1bUQXdz9Rp/MLwRoqAtDOzcaO7rvDrAWW8vhcatWVNjF6cmJre9embkz1947h3YfXgIUgVzblQldxgFH0ZOr/qULwM15k4Zlci4Vd9ZU5ltY71oObHBnBFQBidmUk8kEsOP7Hntwqsb974NfS8PAh7LKoo23Hw+2R4FQcSzKlDPgFOEyf8kx3HW94kQ7xJgRRdAJG7CyIWxgiXNUN0+k5nJLN83k3n8D8eHN3+1ux5+8uBHIKiWt1G1Rn3IJkiUCcQzU3G0h9qWHMeJdoSrwtr9dl6I6DNjFwRRyxiKnStSqkPJPsGSmZ+mp1P9z2dzOy3Klj31yMdmX9S8V75APEsomMZwT9fz9i6vkW9AvEgQyqrBQM2Dq9rrD0gCgXfHA0jpjIRm2Zcw+3CR2tZl27SnMZFSZ1lWcRwZITeDckresAEXaoKwwBh7/WQubgTOQj5BVjdv7KiBJz7bztMNcHIk03JiONNyfiK/ntv2VMHAMx6BjpoA/Gj9Emdjul7W7e6TeQNDK9WJLRm361P5c1drEmAaymaYoXpfjZoiOk7FHWuh5dxEHmzLHiXM9oyTz9FawRZw65f5yyzXBMpd0JGhFKB5nSwRMVvumDv2cxm4m1f5X4AuWhRePDUOtqEPQJVVGfWcBz1ahmPlTlxzqaJLquYZU1HTvjcTMD6dOULM0n+g5nKposHzdWbo7FgEkDBviWlYx++53XtQ33kvDU8dHAJm6L8usdwEZn09S3qiPed5lcCSLUpI0eEA8620zLbDl6bh8T+egkI+/7Rl6kegcTSPst1QUKaM+brhrjnF2yUQJNxnrGMnR7KbTw5nYFVjyAl98w2+VdvVlA67Dw3BgROjAKa+yyrpz0BKTbJnez1NT6AKrrnA1bEi1av2v3xaiL90dnxL2Kc0rsXc4WpcQEc8AEtiGrRiejmK6WWeMDIxtVwwKExijB5KFuBYIg1cy8dx0dTQ/yQVc78yBXMIqJ5i/VvvkqHdSjXuM/THKy7w2LQJ6fpJms38QiHGvlzBt+RwJv2JQ2elbjyRtjIi1AIRMAsKPuQduHVzr2YW+kIBE5BTwOzzxLKOiMX8QVuWh00IpqD+S0WHtLlzefpLBOZo/IYvEqQPnTX5dxmy4xookqaCjRuT4mMi8g3bxs2KCkj3GFj4+QSzA0RkeskU8iCJeUiBDv09Jt8OPEV6k7DlP3gxxh/dAPymPh/Kf5d897dIOd9P7H8oEd4G1JV8wPGbRadx52sgLmrRAZ99EZ5+LZgV+v+4Llrg/wX6HRCxgvzAAwAAAABJRU5ErkJggg==");
  }
  .btn-stripe-connect.light-blue span:before {
    background-image: url("data:image/png;base64,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");
  }
}

You can grab this code straight from Stripe here.

Add a redirect URI

Once the integration takes place on Stripe's end we need a way to bounce users back to our app. Stripe offers this feature within your Connect settings dashboard. Make sure you're viewing Test data and add the following URI within the Redirects area:

http://localhost:3000/users/auth/stripe_connect/callback

This URL exists in our app presently thanks to the gem we installed, devise, and the initial controller logic we set up before this.

While you are in your settings it makes sense to provide some Branding details as well to avoid confusion coming up. I'll call our application "Back My Idea".

stripe-connect-branding

With the Stripe Connect button in place inside our view, we can click on it to land at a page that looks like below. I suggest creating a new user account in the app so you can tie it to a new account instead of your existing if you prefer. It's not a huge deal if you don't at first.

The view below assumes you're signed out of any Stripe account upon visiting. If your account is the master Stripe account (the one containing the client_id) you will be prompted to log out or switch users.

Connect With Stripe Prompt

From here you could fill out the details of the giant form about your account but since we are in development mode, notice the call to action at the very top of the page. We can bypass the form altogether.

If you skip the form and all goes well you should be redirected back to the app with a success message in place! Sweeeeet!

Here are the console logs as proof. I omitted any keys just to be safe.

Started GET "/users/auth/stripe_connect/callback?scope=read_write&code=ac_XXXXXXXXXXXXXXXXXXXXXXXX" for 127.0.0.1 at 2020-01-28 16:26:39 -0600
I, [2020-01-28T16:26:39.694445 #32159]  INFO -- omniauth: (stripe_connect) Callback phase initiated.
Processing by OmniauthCallbacksController#stripe_connect as HTML
  Parameters: {"scope"=>"read_write", "code"=>"ac_XXXXXXXXXXXXXXXXXXXX"}
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/omniauth_callbacks_controller.rb:4:in `stripe_connect'
   (0.1ms)  begin transaction
  ↳ app/controllers/omniauth_callbacks_controller.rb:10:in `stripe_connect'
  User Update (0.4ms)  UPDATE "users" SET "provider" = ?, "uid" = ?, "access_code" = ?, "publishable_key" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["provider", "stripe_connect"], ["uid", "acct_XXXXXXXXXXXXXX"], ["access_code", "sk_test_XXXXXXXXXXXXXXXXXXXXXXXX"], ["publishable_key", "pk_test_XXXXXXXXXXXXXXXXXXXXXXXX"], ["updated_at", "2020-01-28 22:26:41.139168"], ["id", 1]]
  ↳ app/controllers/omniauth_callbacks_controller.rb:10:in `stripe_connect'
   (1.0ms)  commit transaction
  ↳ app/controllers/omniauth_callbacks_controller.rb:10:in `stripe_connect'
Redirected to http://localhost:3000/
Completed 302 Found in 7ms (ActiveRecord: 1.4ms | Allocations: 3826)


Started GET "/" for 127.0.0.1 at 2020-01-28 16:26:41 -0600
Processing by ProjectsController#index as HTML
  Rendering projects/index.html.erb within layouts/application

Navigating to your account settings you should see the updated messaging we added for connected accounts.

Subscriptions/Backings

On the front-end, we need a way for projects to be backable. Doing so requires some form of subscription logic under the hood for each plan. We'll add a way for a given user to configure what tiers they can offer and for how much (much like Patreon).

We'll envoke the nested_attributes strategy for new projects that we'll tie to a new model called Perk. Each Perk will have a price, title, description to start with. A user who hasn't hosted the project can subscribe to any perk respectively.

Create the Perk model

We'll start with the following fields:

  • title
  • amount
  • description
  • quantity - Providing limited quantities affords higher purchases usually
  • project_id - Each perk needs a parent Project to associate to
$ rails g model Perk title:string amount:decimal description:text quantity:integer project:references

Once that generates the new migration we need to amend it a tad. The amount column needs a few constraints and sane defaults since we are dealing with currency.

class CreatePerks < ActiveRecord::Migration[6.0]
  def change
    create_table :perks do |t|
      t.decimal :amount, precision: 8, scale: 2, default: 0
      t.text :description
      t.integer :quantity
      t.references :project, null: false, foreign_key: true

      t.timestamps
    end
  end
end

  • Precision refers to how many numbers to allow. In this case, it can be (999,999.99) but you're welcome to tweak that.
  • Scale refers to how many digits to the right of the decimal point should be present. For most currency that's 2.
$ rails db:migrate

Adding Perks to Project Model

We need to associate perks to projects as well as define the nested characteristics of the association. Doing so results in the following inside my project.rb file

# app/models/project.rb
class Project < ApplicationRecord
  belongs_to :user
  has_rich_text :description
  has_one_attached :thumbnail
  has_many :comments, as: :commentable

  # add the two lines below
  has_many :perks, dependent: :destroy
  accepts_nested_attributes_for :perks, allow_destroy: true, reject_if: proc { |attr| attr['title'].blank? }
  ....

end

Here we say a Project can have multiple perks. The dependent: :destroy declaration means that if a project is deleted, the perks will be also.

The accepts_nested_attributes_for line signifies the ability for our perks to living within our project structure now. We need to extend our project forms to include the fields we added when generating the model. A perk can be destroyed independently via the allow_destroy: true declaration and finally, we validate a perk by not saving if the title field is blank on each new perk.

The Perk Model

The hard work of the perk model is essentially already done thanks to the generator we ran. Rails added belongs_to :project automatically within the perk.rb file.

# app/models/perk.rb
class Perk < ApplicationRecord
  belongs_to :project
end

Whitelisting the new perk fields

On the Projects controller, we need to whitelist the new fields we just created in order to save any really. We can do so pretty easily:

# app/controllers/projects_controller.rb

class ProjectsController < ApplicationController
  ...

  private
    ...
    def project_params
      params.require(:project).permit(:title, :donation_goal, :description, :thumbnail, perks_attributes: [:id, :_destroy, :title, :description, :amount, :quantity])
    end
end

Here I've added a new perks_attributes parameter which points to an array of additional fields. Notice the :_destroy method as well. That will allow us to delete individual perks as necessary.

Updating the views

Our new project form looks like this currently:

<!-- app/views/projects/_form.html.erb-->
<%= form_with(model: project, local: true, multipart: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation" class="bg-white p-6 rounded text-red-500 mb-5">
      <h2 class="text-red-500 font-bold"><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :thumbnail, "Project thumbnail", class: "label" %>
    <%= form.file_field :thumbnail %>
  </div>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :donation_goal, class: "label" %>
    <%= form.text_field :donation_goal, class: "input" %>
  </div>

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

  <div class="mb-6">
    <%= form.submit class: "btn btn-default" %>
  </div>
<% end %>

Nothing out of the ordinary right?

To make this a little more dynamic I'd like to reach for Stimulus.js which is a nice JavaScript library that doesn't take over your entire front-end but rather sprinkles in more dynamic bits of JavaScript where you need them.

The general idea I'd like to introduce is added a new nested perk on click each time we need more than 1. JavaScript is rather necessary to help make this type of user experience more acceptable.

Installing Stimulus JS

Because Rails already ships with Webpacker, we can install Stimulus quite easily.

$ bundle exec rails webpacker:install:stimulus

This command should do the heavy lifting we are after. Once it's complete you should have a new controllers directory within app/javascript and a few additions to your application.js file.

We'll only be using a bit of Stimulus but I encourage you to explore more. It's a fantastic library that works really well with Rails.

Let's create our first controller. I'll delete the hello_controller.js that was created altogether.

// app/javascript/controllers/nested_form_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["add_perk", "template"]

  add_association(event) {
    event.preventDefault()
    var content = this.templateTarget.innerHTML.replace(/TEMPLATE_RECORD/g, new Date().valueOf())
    this.add_perkTarget.insertAdjacentHTML('beforebegin', content)
  }

  remove_association(event) {
    event.preventDefault()
    let perk = event.target.closest(".nested-fields")
    perk.querySelector("input[name*='_destroy']").value = 1
    perk.style.display = 'none'
  }
}

Here we define a new controller and add some targets and methods. I won't be going too deep on the ins and outs of Stimulus but this should be enough to get things rolling.

  • Targets: refer to things you want to target...
  • You can access targets using the this keyword in object notation. In our case that might be this.add_perkTarget.

In our view partial we can extend the form like so:

<!-- app/views/projects/_form.html.erb-->
<%= form_with(model: project, local: true, multipart: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation" class="p-6 mb-5 text-red-500 bg-white rounded">
      <h2 class="font-bold text-red-500"><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :thumbnail, "Project thumbnail", class: "label" %>
    <%= form.file_field :thumbnail %>
  </div>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :donation_goal, class: "label" %>
    <%= form.text_field :donation_goal, class: "input" %>
  </div>

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

  <div class="my-10">
    <h3 class="text-2xl">Perks</h3>

    <div data-controller="nested-form">
      <template data-target='nested-form.template'>
        <%= form.fields_for :perks, Perk.new, child_index: 'TEMPLATE_RECORD' do |perk| %>
          <%= render 'perk_fields', form: perk %>
        <% end %>
      </template>

      <%= form.fields_for :perks do |perk| %>
        <%= render 'perk_fields', form: perk %>
      <% end %>

      <div data-target="nested-form.add_perk">
        <%= link_to "Add Perk", "#", data: { action: "nested-form#add_association" }, class: "btn btn-white" %>
      </div>
    </div>
  </div>

  <div class="mb-6">
    <%= form.submit class: "btn btn-default" %>
  </div>
<% end %>

Our form now was a few more pieces of logic that use Stimulus + Rails to make it a bit more dynamic. You can create however many new Perks you want with a single click. I created a second partial to help render the subfields for perks

<!-- app/views/projects/_perk_fields.html.erb -->
<div class="p-6 mb-4 border rounded nested-fields">

  <%= form.hidden_field :_destroy %>

  <div class="mb-6">
    <%= form.label :title %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :description %>
    <%= form.text_area :description, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :amount %>
    <%= form.text_field :amount, placeholder: "0.00", class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :quantity %>
    <%= form.text_field :quantity, placeholder: "10", class: "input" %>
  </div>

  <%= link_to "Remove", "#", data: { action: "click->nested-form#remove_association" }, class: "text-white underline text-sm" %>
</div>

If all goes smoothly you should be able to create a new project with perks and save it. We still need to render the project perks in the project show view so let's update that next.

Project Show view

The show view of a project can be either active or inactive based on the status we set up before. The main show template gets some simple code to account for this:

<!--app/views/projects/show.html.erb-->
<% if @project.active? %>
  <%= render "active_project", project: @project %>
<% else %>
  <%= render "inactive_project", project: @project %>
<% end %>

Active projects will have more data since it's what most users will interact with.

The active view

<!-- app/views/projects/active_project.html.erb-->
<div class="container relative p-6 mx-auto text-gray-900 bg-white rounded-lg lg:p-10">
  <div class="flex flex-wrap items-center justify-between w-full pb-4 mb-8 border-b-2 border-gray-200">
    <div class="flex flex-wrap items-start justify-between w-full pb-4 mb-4 border-b-2 border-gray-200">
      <div class="flex-1">
        <h1 class="text-3xl font-bold leading-none text-gray-800"><%= project.title %></h1>
        <p class="text-sm italic text-gray-500">Created by <%= project.user.name ||=
      project.user.username %></p>
      </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%= number_to_currency(project.current_donation_amount) %></p>
      <p class="text-sm text-gray-500">pledged of <%= number_to_currency(project.donation_goal) %></p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">200</p>
      <p class="text-sm text-gray-500">backers</p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%= distance_of_time_in_words_to_now(project.expires_at) %></p>
      <p class="text-sm text-gray-500">to go</p>
    </div>
  </div>

  <div class="flex flex-wrap items-start justify-between w-full mb-6">
    <div class="w-full lg:w-3/5">
    <% if project.thumbnail.present? %>
      <%= image_tag project.thumbnail, class: "rounded" %>
    <% else %>
      <div class="flex items-center justify-center p-8 text-center bg-gray-100 rounded">
        <div class="">
          <p class="text-xs font-bold text-gray-600 uppercase">PROJECT</p>
          <h3 class="text-2xl text-black"><%= project.title %></h3>
        </div>
      </div>
    <% end %>
      <div class="my-6">
        <%= render "comments/comments", commentable: project  %>
        <%= render "comments/form", commentable: project %>
      </div>
    </div>

    <div class="w-full mt-6 lg:pl-10 lg:w-2/5 lg:mt-0">
      <div class="mb-6">
        <p class="text-sm font-semibold text-gray-500 uppercase">Description</p>
        <%= project.description %>
      </div>

      <h3 class="text-2xl text-gray-900">Back this idea</h3>

      <% project.perks.each do |perk| %>
        <div class="p-4 mb-6 bg-gray-100 border rounded">
          <h4 class="text-lg font-bold text-center text-gray-900"><%= perk.title %></h4>
          <p class="pb-2 mb-2 text-sm italic text-center border-b"><%= perk.quantity %> left</p>
          <div class="py-2 text-gray-700">
            <%= simple_format perk.description %>
          </div>

          <% if user_signed_in? && perk.project.user_id == current_user.id %>
            <em class="block text-sm text-center">Sorry, You can't back your own idea</em>
          <% else %>
          <%= link_to "Back this idea for #{number_to_currency(perk.amount)} /mo", new_subscription_path(amount: perk.amount, project: project), class: "btn btn-default btn-expanded" %>
          <% end %>
        </div>
      <% end %>
    </div>
  </div>

 <% if admin? || author_of(project) %>
    <div class="absolute top-0 right-0 mt-4 mr-4">
      <%= link_to 'Edit', edit_project_path(project), class: "btn btn-sm btn-outlined btn-default" %>
      <%= link_to 'Delete', project_path(project), method: :delete, class: "btn btn-sm btn-outlined btn-default", data: { confirm: "Are you sure?" } %>
    </div>
  <% end %>
</div>

For now we have the UI for what we need with regards to perks and the project. There are a few areas to improve upon but we'll focus on those in a bit.

The inactive view:

We won't render any way to back the project and show obvious signs of inactivity.

<!-- app/views/projects/inactive_project.html.erb-->
<div class="container relative p-6 mx-auto text-gray-900 bg-white border-2 border-red-500 rounded-lg lg:p-10">
  <div class="flex flex-wrap items-center justify-between w-full pb-4 mb-8 border-b-2 border-gray-200">
    <div class="flex flex-wrap items-start justify-between w-full pb-4 mb-4 border-b-2 border-gray-200">
      <div class="flex-1">
        <span class="px-2 py-1 text-xs font-semibold text-white bg-red-500 rounded-lg">Inactive</span>

        <h1 class="mt-4 text-3xl font-bold leading-none text-gray-800"><%= project.title %></h1>
        <p class="text-sm italic text-gray-500">Created by <%= project.user.name ||=
      project.user.username %></p>
      </div>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none"><%= number_to_currency(project.current_donation_amount) %></p>
      <p class="text-sm text-gray-500">pledged of <%= number_to_currency(project.donation_goal) %></p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">200</p>
      <p class="text-sm text-gray-500">backers</p>
    </div>

    <div class="w-full mb-4 lg:w-1/5 lg:mb-0">
      <p class="m-0 text-xl font-semibold leading-none">Expired</p>
      <p class="text-sm text-gray-500"><%= time_ago_in_words(@project.expires_at) %> ago</p>
    </div>
  </div>

  <div class="flex flex-wrap items-start justify-between mb-6">
    <div class="w-full lg:w-3/5">
    <% if project.thumbnail.present? %>
      <%= image_tag project.thumbnail, class: "rounded" %>
    <% else %>
      <div class="flex items-center justify-center p-8 text-center bg-gray-100 rounded">
        <div class="">
          <p class="text-xs font-bold text-gray-600 uppercase">PROJECT</p>
          <h3 class="text-2xl text-black"><%= project.title %></h3>
        </div>
      </div>
    <% end %>
    <div class="my-6">
      <%= render "comments/comments", commentable: project  %>
      <p>Comments are closed for inactive projects</p>
    </div>
    </div>
    <div class="w-full mt-6 lg:pl-10 lg:w-2/5 lg:mt-0">
      <p class="text-sm font-semibold text-gray-500 uppercase">Description</p>
      <%= project.description %>
    </div>
  </div>

  <% if admin? || author_of(project) %>
    <div class="absolute top-0 right-0 mt-4 mr-4">
      <%= link_to 'Edit', edit_project_path(project), class: "btn btn-sm btn-outlined btn-default" %>
      <%= link_to 'Delee', project_path(project), method: :delete, class: "btn btn-sm btn-outlined btn-default", data: { confirm: "Are you sure?" } %>
    </div>
  <% end %>
</div>

Perk subscriptions

Adding subscriptions to each Perk should come pretty standard but with Stripe Connect there are a few more steps and concerns.

Subscription Controller and routes

We need a way to pass some requests with the necessary data to make our charges work as planned. It makes sense to silo that into a subscription controller with routes.

# config/routes.rb

resource :subscription # add this anywhere

You may need to reboot your server for these changes to take effect.

We can add a views folder at app/views/subscriptions next. Inside we need a few views.

back-my-idea/app/views/subscriptions
.
├── _form.html.erb
├── new.html.erb
└── show.html.erb

The controller is where a lot of the magic happens. Here we need access to the current user, the project data, and the client data (Back My Idea)

With the Pay gem, we can create subscriptions quite easily. From each new payment form, we will pass parameters through for the values we need to create the initial charge and start the subscription.

Here's the controller at its current state. We need a way to grab information passed through the request upon clicking a "Back this project" button in our views.

# app/controllers/subscriptions_controller.rb
class SubscriptionsController < ApplicationController
  def new
    @project = Project.find(params[:project])
  end

  def create
  end

  def destroy
  end
end

In the request we have access to the project via params thanks to passing them through to each Perk's button:

<!-- app/views/projects/_active_project.html.erb-->

<!-- omitted code--->

<h3 class="text-2xl text-gray-900">Back this idea</h3>

<% project.perks.each do |perk| %>
  <div class="p-4 mb-6 bg-gray-100 border rounded">
    <h4 class="text-lg font-bold text-center text-gray-900"><%= perk.title %></h4>
    <p class="pb-2 mb-2 text-sm italic text-center border-b"><%= perk.quantity %> left</p>
    <div class="py-2 text-gray-700">
      <%= simple_format perk.description %>
    </div>
    <%= link_to "Back this idea for #{number_to_currency(perk.amount)} /mo", new_subscription_path(amount: perk.amount, project: project), class: "btn btn-default btn-expanded" %>
  </div>
<% end %>

<!-- omitted code--->

With a link_to method, we can pass information through via the URL so this could equate to something like:

http://localhost:3000/subscription/new?amount=10.0&project=4

From that we can grab it in the controller and do whatever. Pretty nifty!

Let's first add our form to the subscription views so we have somewhere to start. Inside app/views/subscriptions/new.html.erb I'll add;

<div class="w-1/2 mx-auto">
  <h3 class="mb-2 text-2xl font-bold text-center">You're about to back <em><%= @project.title %> </em></h3>

  <div class="p-6 border rounded">
    <%= render "form" %>
  </div>
</div>

And inside the form partial:

<!-- app/views/subscriptions/_form.html.erb-->
<%= form_with model: current_user, url: subscription_url, method: :post, html: { id: "payment-form" }  do |form| %>
  <div>
    <label for="card-element">
      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>
  </div>

  <button>Submit Payment</button>
<% end %>

We'll enhance this in a bit but first, we need to tie some JavaScript to it

JavaScript

There is still a need for capturing Stripe tokens on the frontend and handing them off to the server so we'll implement Stripe elements as well. Let's start there:

First, we need to add the JS library to the app. I'll add it in the application layout file

<!-- app/views/layouts/application.html.erb-->
<!DOCTYPE html>
<html>
  <head>
    <title>Back My Idea</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <%= stylesheet_link_tag  'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= stylesheet_pack_tag  'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag  'application', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'https://js.stripe.com/v3/' %>

    <!-- tons more code -->

Next, we need some JavaScript to prevent the default form from submitting and tokenizing the charge. We'll also need to pass the given users' publishable key through. I'll create a new stripe.js file as a catch-all place to work our magic. This could be better optimized as a Stimulus Controller or more reusable javascript component but I'm not entirely worried about that at this stage.

I'll approach this in an Object-Oriented way where we can create an ES6 class to allow for the given Project owner's publishable Stripe key to be passed in as a parameter.

class StripeCharges {
  constructor({ form, key }) {
    this.form = form;
    this.key = key;
    this.stripe = Stripe(this.key)
  }

  initialize() {
    this.mountCard()
  }

  mountCard() {
    const elements = this.stripe.elements();

    const style = {
      base: {
        color: "#32325D",
        fontWeight: 500,
        fontSize: "16px",
        fontSmoothing: "antialiased",

        "::placeholder": {
          color: "#CFD7DF"
        },
        invalid: {
          color: "#E25950"
        }
      },
    };

    const card = elements.create('card', { style });
    if (card) {
      card.mount('#card-element');
      this.generateToken(card);
    }
  }

  generateToken(card) {
    let self = this;
    this.form.addEventListener('submit', async (event) => {
      event.preventDefault();

      const { token, error } = await self.stripe.createToken(card);

      if (error) {
        const errorElement = document.getElementById('card-errors');
        errorElement.textContent = error.message;
      } else {
        this.tokenHandler(token);
      }
    });
  }

  tokenHandler(token) {
    let self = this;
    const hiddenInput = document.createElement('input');
    hiddenInput.setAttribute('type', 'hidden');
    hiddenInput.setAttribute('name', 'stripeToken');
    hiddenInput.setAttribute('value', token.id);
    this.form.appendChild(hiddenInput);

    ["brand", "last4", "exp_month", "exp_year"].forEach(function (field) {
      self.addCardField(token, field);
    });
    this.form.submit();
  }

  addCardField(token, field) {
    let hiddenInput = document.createElement('input');
    hiddenInput.setAttribute('type', 'hidden');
    hiddenInput.setAttribute('name', "user[card_" + field + "]");
    hiddenInput.setAttribute('value', token.card[field]);
    this.form.appendChild(hiddenInput);
  }
}

// Kick it all off
document.addEventListener("turbolinks:load", () => {
  const form = document.querySelector('#payment-form')
  if (form) {
    const charge = new StripeCharges({
      form: form,
      key: form.dataset.stripeKey
    });
    charge.initialize()
  }
})

Inside application.js I'll import this file:

// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.

require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")

require("trix")
require("@rails/actiontext")

import "stylesheets/application"
import "controllers"
import "components/stripe";

Finally, in the views I'll add updates to our form partial and new view.

<!-- app/views/subscriptions/new.html.erb-->
<%= form_with model: current_user, url: subscription_url, method: :post, html: { id: "payment-form", class: "stripe-form" }, data: { stripe_key: project.user.publishable_key }  do |form| %>
  <div>
    <label for="card-element" class="label">
      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" class="text-sm text-red-400"></div>
  </div>

  <button>Back <%= number_to_currency(params[:amount]) %> /mo toward <em><%= project.title %></em></button>
<% end %>

This should mount the form using the user's publishable key on file who owns the project. We'll be adding fields to this form to send data over the wire that we need such as the amount and customer id.

I've added some styles to make it look more presentable as well:

/* app/javascript/stylesheets/components/_forms.scss */

// Stripe form
.stripe-form {
  @apply bg-white rounded-lg block p-6;

  * {
    @apply font-sans text-base font-normal;
  }

  label {
    @apply text-gray-900;
  }

  .card-only {
    @apply block;
  }

  .StripeElement {
    @apply border px-3 py-3 rounded-lg;
  }

  input, button {
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    outline: none;
    border-style: none;
    color: #fff;
  }

  input:-webkit-autofill {
    transition: background-color 100000000s;
    -webkit-animation: 1ms void-animation-out;
  }

  input {
    -webkit-animation: 1ms void-animation-out;
  }

  input::-webkit-input-placeholder {
    @apply text-gray-400;
  }

  input::-moz-placeholder {
    @apply text-gray-400;
  }

  input:-ms-input-placeholder {
    @apply text-gray-400;
  }

  button {
    @apply bg-indigo-500 rounded mt-4 text-white cursor-pointer w-full block h-10;
  }

  button:active {
    @apply bg-indigo-600;
  }

  .error {
    svg {
      .base {
        fill: #e25950;
      }

      .glyph {
        fill: #f6f9fc;
      }
    }

    .success {
      .icon .border {
        stroke: #ffc7ee;
      }

      .icon .checkmark {
        @apply bg-indigo-500;
      }

      .title {
        color: #32325d;
      }

      .message {
        color: #8898aa;
      }

      .reset path {
        @apply bg-indigo-500;
      }
    }
  }
}

Up until this point I had some demo content in my app. I'd rather start clean from here on out so I'll reset the database and also delete the accounts on Stripe that were created prior within the Stripe Connect test data area.

$ rails db:reset

Making sure a user who creates a project has Stripe keys

A nice way to force a user to authenticate with Stripe is to use some conditional logic when they sign up and go to create a new Project. I've modified our projects/new.html.erb view a tad to render the Stripe button right in line.

<!-- app/projects/new.html.erb-->
<div class="max-w-lg m-auto">
 <% if current_user.can_receive_payments? %>
    <h1 class="mb-6 text-3xl font-bold">Create a Project</h1>
    <%= render 'form', project: @project %>
  <% else %>
  <div class="p-6 bg-white rounded-lg">
    <h1 class="text-3xl font-bold text-gray-900">Authorize Stripe</h1>
    <p class="mb-6 text-gray-900">Before you can configure a new project we need you to connect to Stripe</p>
    <%= stripe_connect_button %>
  </div>
  <% end %>
</div>

Signing in / Sign up fixups

You may notice a new "Sign in with Stripe Connect" link when visiting the sign-up or sign in path. We don't really want this to be present for Stripe connect so a condition should get the job done. Devise has a partial called _links.html.erb you can amend. Look for the block below and add the unless provider == :stripe_connect logic.

<!- app/views/devise/_links.html.erb-->
<!-- .. more code here.. -->
<%- if devise_mapping.omniauthable? %>
  <%- resource_class.omniauth_providers.each do |provider| %>
    <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), class: "link link-grey block py-2" unless provider == :stripe_connect %>
  <% end -%>
<% end -%>

Controller Logic

Our front-end is behaving as expected but we still need the serverside component to submit the charge to Stripe successfully. On the POST request we'll hone in the the create method in the subscriptions_controller.rb file to complete the circle.

In order to use subscriptions with Stripe Connect we also are met with a requirement of creating plans dynamically via Stripe based on the tiers on each given project.

Doing this requires a bit of logic behind the scenes that will generate each tier's plan dynamically via the Stripe API. I'll reach for another Job to do as such so we an queue these up in a more performant manner.

$ rails g job create_perk_plans

This creates a new job file. Inside I've added the following logic:

class CreatePerkPlansJob < ApplicationJob
  queue_as :default

  def perform(project)
    key = project.user.access_code
    Stripe.api_key = key

    project.perks.each do |perk|
      Stripe::Plan.create({
        id: "#{perk.title.parameterize}-perk_#{perk.id}",
        amount: (perk.amount.to_r * 100).to_i,
        currency: 'usd',
        interval: 'month',
        product: { name: perk.title },
        nickname: perk.title.parameterize
      })
    end
  end
end

This job sets up the Stripe API to use the user who is creating the project's information. When a new plan is created we pass in each perk's info inside an each loop. We need a unique ID we can use later to retrieve the plan during subscription setup.

To get this to kick off we need to call the class in our projects controller.

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  ...
  def create
    @project = Project.new(project_params)
    @project.user_id = current_user.id

    respond_to do |format|
      if @project.save
        if @project.perks.any? && current_user.can_receive_payments?
          CreatePerkPlansJob.perform_now(@project) # you can also perform later if you like
        end
        ExpireProjectJob.set(wait_until: @project.expires_at).perform_later(@project)
        format.html { redirect_to @project, notice: 'Project was successfully created.' }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end
  ...
end

In the controller I added a new conditional that makes sure a project has perks (it should) and the current user can actually receive payments. Considering they got this far they should already be able to accept payments but I wanted to be extra sure.

Testing it out yourself

If you authenticate a new Stripe account in the app, create a new project with a perk or two, and then head to your Stripe dashboard, you should hopefully see a new product with pricing and other information.

Subscribing users

Each user will need a customer id associated it for Stripe to reference. This will either be used or added at the time of charge. We need to add a column to our users table in the database as a result. I'll also add a subscribed boolean property to signify if a user is indeed subscribed.

$ rails g migration add_subscription_fields_to_users stripe_id:string stripe_subscription_id:string subscribed:boolean current_plan:string card_last4:string card_exp_month:string card_exp_year:string card_type:string
$ rails db:migrate

Now that we can dynamically add plans for users we are ready to initialize subscriptions. Doing this will happen inside the subscriptions_controller.rb. We need a few values which will be parameters passed through the request. It look like we still need a plan id based on the perk a given user wants to support. We can update the perk button parameters to include it.

<!-- app/views/projects/_active_project.html.erb-->
<!-- ... more code... -->
<h3 class="text-2xl text-gray-900">Back this idea</h3>

<% project.perks.each do |perk| %>
    <div class="p-4 mb-6 bg-gray-100 border rounded">
      <h4 class="text-lg font-bold text-center text-gray-900"><%= perk.title %></h4>
      <p class="pb-2 mb-2 text-sm italic text-center border-b"><%= perk.quantity %> left</p>
      <div class="py-2 text-gray-700">
        <%= simple_format perk.description %>
      </div>

      <% if user_signed_in? && perk.project.user_id == current_user.id %>
        <em class="block text-sm text-center">Sorry, You can't back your own idea</em>
      <% else %>
      <%= link_to "Back this idea for #{number_to_currency(perk.amount)} /mo", new_subscription_path(amount: perk.amount, project: project, plan: "#{perk.title.parameterize}-perk_#{perk.id}"), class: "btn btn-default btn-expanded" %>
      <% end %>
    </div>
  <% end %>
 <!-- ... more code... -->

The link_to got a new parameter passed called plan with the value of perk.title.parameterize which will match the nickname we gave each Stripe plan when a new project was created. Remember we ran that job in the background to perform those tasks dynamically.

Subscription logic

I'd love to be able to use the Pay here but it's not quite clear from the docs if Stripe Connect is supported. To play it safe we'll refer to the Stripe docs instead.

class SubscriptionsController < ApplicationController
  before_action :authenticate_user!

  def new
    @project = Project.find(params[:project])
  end

  # Reference:
  # https://stripe.com/docs/connect/subscriptions
  def create
    @project = Project.find(params[:project])
    key = @project.user.access_code
    Stripe.api_key = key

    plan_id = params[:plan]
    plan = Stripe::Plan.retrieve(plan_id)
    token = params[:stripeToken]

    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 = Stripe::Subscription.create({
      customer: customer,
      items: [
        {
          plan: plan
        }
      ],
      expand: ["latest_invoice.payment_intent"],
      application_fee_percent: 10,
    }, stripe_acccount: key)


    options = {
      stripe_id: customer.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_brand]
    )

    current_user.update(options)

    redirect_to root_path, notice: "Your subscription was setup successfully!"
  end

  def destroy
  end
end

Here is where I landed so far. We need a few variables coming from different places.

  • Our own internal API key is set app-wide so we can access the Stripe class without problems.
  • We also need the API key of the given project author.
  • The plan is derived from the perk titles and dynamic Stripe plans that should already be created at this point. We'll need to add a hidden field which adds this value during the POST request in the parameters.
  • The token comes from the front-end during form submission. The Stripe JavaScript we added handles this.

We first check if a customer exists in our database, if so we use their customer ID to create the subscription rather than creating a whole new customer each time. If a customer doesn't exist we create one dynamically using the Stripe API. We pass the user's email and the token.

Finally the subscription requires the customer id, plan type, and stripe account key of the project author. Our platform once a cut of each recurring monthly fee which in this case is 10%.

Let's add that hidden fields to the form so we have access to them during form submission.

<!-- app/views/subscriptions/_form.html.erb-->
<%= form_with model: current_user, url: subscription_url, method: :post, html: { id: "payment-form", class: "stripe-form" }, data: { stripe_key: project.user.publishable_key }  do |form| %>
  <div>
    <label for="card-element" class="label">
      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" class="text-sm text-red-400"></div>
  </div>

  <input type="hidden" name="plan" value="<%= params[:plan] %>">
  <input type="hidden" name="project" value="<%= params[:project] %>">

  <button>Back <%= number_to_currency(params[:amount]) %> /mo toward <em><%= project.title %></em></button>
<% end %>


Test drive!

Let's see if we get things to work.

My console outputs what I'm after for another user

<User card_type: "Visa", id: 2, email: "[email protected]", username: "jsmitty", name: "John Smith", admin: false, created_at: "2020-01-30 20:46:18", updated_at: "2020-01-30 20:50:58", uid: nil, provider: nil, access_code: nil, publishable_key: nil, stripe_id: "cus_Ge21Ymdr4iI9q5", subscribed: true, current_plan: "perky-perk_1", card_last4: "4242", card_exp_month: "4", card_exp_year: "2024">
irb(main):006:0>

This user is merely a subscriber. They haven't authenticated with Stripe Connect nor posted a project hence the nil values.

We can verify in Stripe the charge. This Stripe account is the user who created the project.

Stripe charge screenshot

In a new private browsing window, I opened my main Connect Account and head to the Collected Fees area. We defined 10% per month charge so our fee works out to $2.00 in this case.

connect account collected fees

Success!

Destroying Subscriptions

It's definitely a requirement to give users the ability to cancel a subscription. We can do this from the account area and reuse the subscription controller to factor in that logic. We'll essentially be undoing what we did on the create method.

class SubscriptionsController < ApplicationController
  ...
  def destroy
    subscription_to_remove = params[:id]
    customer = Stripe::Customer.retrieve(current_user.stripe_id)
    customer.subscriptions.retrieve(subscription_to_remove).delete
    current_user.subscribed = false

    redirect_to root_path, notice: "Your subscription has been canceled."
  end
end

The main logic is in place here since we have access to the stripe customer id now. We can retrieve their subscription, cancel their plan and update their records to be not subscribed.

Let's render a user's current subscription and a link to cancel in their account settings. See the bottom of the code snippet for the newest addition.

<!-- app/view/devise/registrations/edit.html.erb-->
<div class="container flex flex-wrap items-start justify-between mx-auto">
  <div class="w-full lg:w-1/2">
  <h2 class="pt-4 mb-8 text-4xl font-bold heading">Account</h2>

  <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>

    <%= render "devise/shared/error_messages", resource: resource %>

    <div class="mb-6">
      <%= f.label :username, class:"label" %>
      <%= f.text_field :username, autofocus: true, class:"input" %>
    </div>

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

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

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

    <div class="mb-6">
      <%= f.label :password, class:"label" %>
      <%= f.password_field :password, autocomplete: "new-password", class:"input" %>
      <p class="pt-1 text-sm italic text-grey-dark"> <% 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" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input" %>
    </div>

    <div class="mb-6">
      <%= f.label :current_password, class: "label" %>
      <%= f.password_field :current_password, autocomplete: "current-password", class: "input" %>
      <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: "btn btn-default" %>
    </div>
    <% end %>

    <hr class="mt-6 mb-3 border" />

    <h3 class="mb-4 text-xl font-bold heading">Cancel my account</h3>

    <div class="flex items-center justify-between">
      <div class="flex-1"><p class="py-4">Unhappy?</p></div>

      <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete, class: "btn btn-default" %>
    </div>
  </div>
<div class="w-full text-left lg:pl-16 lg:w-1/2">
  <div class="p-6 mt-10 text-gray-900 bg-white rounded shadow-lg">
    <% unless resource.can_receive_payments? %>
      <h4 class="mb-6 text-xl font-semibold leading-none text-gray-900">You wont be able to sell items until you register with Stripe!</h4>
      <%= stripe_connect_button %>
    <% else %>
      <h4 class="mb-6 text-xl font-semibold leading-none text-gray-900">Successfully connected to Stripe  ✅</h4>
    <% end %>
  </div>

  <% if resource.subscribed? %>
   <% customer = Stripe::Customer.retrieve(current_user.stripe_id) %>
    <% if customer.subscriptions.any? %>
    <div class="p-6 mt-10 text-gray-900 bg-white rounded shadow-lg">
      <h4 class="font-bold text-gray-900">Active subscriptions</h4>
      <ul>
        <% customer.subscriptions.list.data.each do |sub| %>
        <li class="flex items-center justify-between py-4 border-b">
          <div><%= sub.plan.nickname %></div>
          <%= link_to "Cancel Subscription", subscription_path(id: sub.id), method: :delete, data: { confirm: "Are you sure?" } %>
        </li>
        <% end %>
      </ul>
    </div>
    <% end %>
   <% end %>
</div>

Yes, this is crappy code. We're doing too much logic in the view but it's only to prove the functionality. I highly recommend moving some of this to a controller. We're essentially querying the Stripe API for the current user's subscription list based on their stripe_id. In doing so we can link to our new destroy action passing the subscription id as a parameter through to the controller to round out the subscription cancellation request.

Project Clean up

We have some housekeeping to do with regards to analytics for projects as new subscribers back new ideas. Our backing count is completely static at this point. We need to add a field and make that dynamic for each new subscriber.

$ rails g migration add_backings_count_to_projects backings_count:integer

We'll set the default to 0

class AddBackingsCountToProjects < ActiveRecord::Migration[6.0]
  def change
    add_column :projects, :backings_count, :integer, default: 0
  end
end

$ rails db:migrate

Inside the Subscription controller we can update counts pretty quickly once a new subscription occurs.

# app/controllers/subscriptions_controller.rb
...

# Update project attributes
project_updates = {
  backings_count: @project.backings_count.next,
  current_donation_amount: @project.current_donation_amount + (plan.amount/100).to_i,
}
@project.update(project_updates)
redirect_to root_path, notice: "Your subscription was setup successfully!"
....

That gets us 1 backer and adds the monthly amount to the monthly backed goal.

We can update the views as a result in both _active_project.html.erb and inactive_project.html.erb.

<!-- app/views/
<!-- more code -->
<div class="w-full mb-4 lg:w-1/5 lg:mb-0">
  <p class="m-0 text-xl font-semibold leading-none"><%= project.backings_count %></p>
  <p class="text-sm text-gray-500">backers</p>
</div>
<!-- more code -->

Keep users from subscribing twice

Right now we don't have a great way to tell if a user has subscribed to a specific perk. There's likely a much better way to track this but I'm going to rely on a simple array for now. This is much easier using Postgresql so I'm actually going to scrap our data so far in favor of switching to Postgresql (you'd need to at some point to deploy this anywhere anyways).

$ rails db:system:change --to=postgresql

If you get a warning about overwriting a database.yml file just type Y to continue. This adds the pg gem inside your Gemfile and updates your config/database.yml file. Doing this erases all your data so we'll need to create some more. Before we do I want to add a field on the users table that will store an array of values which will be the plans the user is subscribed to.

$ rails g migration add_perk_subscriptions_to_users perk_subscriptions:text

Then we will modify that file:

class AddPerkSubscriptionsToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :perk_subscriptions, :text, array:true, default: []
  end
end

$ rails db:create 
$ rails db:migrate

Subscription perk_subscriptions logic

We need to append to the perk_subscriptions array once a user has purchased a plan. Doing so can happen in the subscriptions controller

class SubscriptionsController < ApplicationController
  ...
  options = {
      stripe_id: customer.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_brand]
    )

    current_user.perk_subscriptions << plan_id # add this line
    current_user.update(options)
  ...

end

We add the identifier to the array. My logs come back like this via rails console to confirm it worked!

irb(main):001:0> User.last
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 2, email: "[email protected]", username: "jsmitty", name: "John Smith", admin: false, created_at: "2020-01-31 19:35:13", updated_at: "2020-01-31 19:41:28", uid: nil, provider: nil, access_code: nil, publishable_key: nil, stripe_id: "cus_GeO35Z7cUaFle0", subscribed: true, card_last4: "4242", card_exp_month: "4", card_exp_year: "2024", card_type: "Visa", perk_subscriptions: ["totes-cool-perk-perk_1"]>

Note the perk_subscriptions column now contains an entry within the array!

Why do we need this? Well, we don't want the same user subscribing to the same perk twice unless they unsubscribed first. In our view we can add a helper to query for the perk identifier. If it finds it in the array we won't display the button to subscribe.

<!-- app/views/projects/_active_project.html.erb-->
<!-- a bunch more code-->

<% project.perks.each do |perk| %>
  <div class="p-4 mb-6 bg-gray-100 border rounded">
    <h4 class="text-lg font-bold text-center text-gray-900"><%= perk.title %></h4>
    <p class="pb-2 mb-2 text-sm italic text-center border-b"><%= perk.quantity %> left</p>
    <div class="py-2 text-gray-700">
      <%= simple_format perk.description %>
    </div>

    <% if user_signed_in? && perk.project.user_id == current_user.id %>
      <em class="block text-sm text-center">Sorry, You can't back your own idea</em>
    <% else %>
     <% if purchased_perk(perk) %>
        <p class="text-sm">You're already subscribed to this perk. <%= link_to "Manage your subscriptions in your account settings", edit_user_registration_path, class: "text-blue-500 underline" %>.</p>
      <% else %>
       <%= link_to "Back this idea for #{number_to_currency(perk.amount)} /mo",  new_subscription_path(amount: perk.amount, project: project, plan: "#{perk.title.parameterize}-perk_#{perk.id}"), class: "btn btn-default btn-expanded" %>
      <% end %>
    <% end %>
  </div>
<% end %>

<!-- a bunch more code-->

I extracted logic into a helper here called purchased_perk(perk). That gets extracted to our projects_helper.rb file.

module ProjectsHelper
  def purchased_perk(perk)
    user_signed_in? && current_user.perk_subscriptions.include?("#{perk.title.parameterize}-perk_#{perk.id}")
  end
end

Here's what I ended up with. We pass the same identifier to the include? method which is a godsend from Ruby to look up array values.

What if a subscription is removed?

We need to do the reverse in the destroy method inside the subscriptions_controller.rb file. Let's address that now.

def destroy
    subscription_to_remove = params[:id]
    plan_to_remove = params[:plan_id] # add this line
    customer = Stripe::Customer.retrieve(current_user.stripe_id)
    customer.subscriptions.retrieve(subscription_to_remove).delete
    current_user.subscribed = false
    current_user.perk_subscriptions.delete(plan_to_remove) # add this line
    current_user.save
    redirect_to root_path, notice: "Your subscription has been cancelled."
  end

We need access to the plan id so we'll need to update the edit account view as a result. That's a pretty easy change:

<!-- app/views/devise/registrations/edit.html.erb-->
<!-- more code -->
<% if resource.subscribed? %>
   <% customer = Stripe::Customer.retrieve(current_user.stripe_id) %>
    <% if customer.subscriptions.any? %>
    <div class="p-6 mt-10 text-gray-900 bg-white rounded shadow-lg">
      <h4 class="font-bold text-gray-900">Active subscriptions</h4>
      <ul>
        <% customer.subscriptions.list.data.each do |sub| %>
        <li class="flex items-center justify-between py-4 border-b">
          <div><%= sub.plan.nickname %></div>
          <%= link_to "Cancel Subscription", subscription_path(id: sub.id, plan_id: sub.plan.id), method: :delete, data: { confirm: "Are you sure?" } %>
        </li>
        <% end %>
      </ul>
    </div>
    <% end %>
   <% end %>
<!-- more code -->

Notice we now pass the plan_id parameter through the "Cancel subscription" link. That gets passed to the controller and then on to Stripe. Cool!

Now when you subscribe to a perk as a customer you should see a notice instead of the subscribe button once a plan is active. If you unsubscribe that button then returns.

Closing thoughts and where to take things from here

There are plenty of improvements to be made to this application but I hope it gave you a broader look at using Stripe for a marketplace solution. Stripe Connect is very powerful and honestly quite easy to integrate. You'll need two sides to a marketplace with your own platform in the middle. From there the possibilities are kind of endless.

This app does the following:

  • Provides a place for ideas to be backed
  • Any user can connect their Stripe account and start a new project
  • Any user can back a project
  • Authenticated users can comment on projects
  • Projects auto-expire after their 30-day limit thanks to background jobs
  • Perks are dynamically added to projects and new Stripe subscription plans are added dynamically once a new project is authored.

What could we enhance?

  • Tests, Tests, Tests! (I ran out of time so I didn't focus on tests)
  • Have a defined Subscription model for keeping track of customers and subscriptions in-app.
  • Hook into Stripe's webhooks to get real-time updates when a User's stripe merchant account needs more information or has something that needs to be updated.
  • Add transactional emails around changes/subscriptions/stripe events.
  • More dynamic backing states. You might notice once a customer unsubscribes our metrics don't revert. This isn't a huge deal but is something that should probably be addressed.
  • The UI sucks but it works

Leave a reply

Sign in or Sign up to leave a response.

2 responses

John
Icons/flag
Icons/link diagonal

Nice

Ettah Clifford Joe
Icons/flag
Icons/link diagonal

Thanks a lot for these series.
They have really helped learn and come up with an MVP (www.brainstaq.com). In my part of the world Rails is almost not known, and integrating payment platforms (especially African payment platforms) in Rails seems so difficult. I was wondering if you would ever consider doing a series on integrating Rails with any of the African payment platforms.

Thanks for the good works!

Est. reading time: 95 minutes
Stats: 3,679 views

Collection

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