February 12, 2020
•Last updated November 5, 2023
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.
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
- 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:
- Sanitize that data (strip tags, html, etc..)
- 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
- I deleted the folder
comments
that was generated withinapp/views/projects
- A deleted the
create.html.erb
file insideapp/views/comments
- 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 deletedtest/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:
You can find your stripe connect client id within your Account settings
You can find your publishable key and private key in the developers area
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.key
that 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,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");
}
.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".
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.
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 usuallyproject_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 bethis.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 thePOST
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.
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.
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
Categories
Collection
Part of the Let's Build: With Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.