Chapter 24

Selling Books

Alexandria is looking good! It’s almost ready to be released - people can sign up, log in, and browse books. But they can’t buy anything yet - that’s kind of problematic for an e-commerce website.

Luckily, that’s a feature we are about to add. By adding prices to books and adding a bunch of new controllers, people will be able to buy books and download them.

24.1. Adding Prices to Books

Before people can buy anything, we need to add a new attribute to our books: a price! We will get some help from a very nice gem that encapsulate all the logic to handle money, (aptly named Money), and made easy to use with Rails with the money-rails gem.

First, add the gem to your Gemfile.

# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.5.0'

gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'

# Hidden Code

Get it installed with bundle.

bundle install

Next, run the money-rails generator to create the configuration file.

rails g money_rails:initializer

Output

Running via Spring preloader in process 16821
      create  config/initializers/money.rb

This created a new initializer, config/initializers/money.rb. We only need to support USD in Alexandria, so let’s update the configuration contained in this initializer.

# config/initializers/money.rb

MoneyRails.configure do |config|
  config.default_currency = :usd
end

Now, let’s generate a new migration to add the price to our books. We also want to add a field named download_url that will be used to retrieve the book and send it to the user after purchase.

rails g migration AddPriceAndDownloadUrlToBooks

Here is the migration:

# db/migrate/TIMESTAMP_add_price_and_download_url_to_books.rb
class AddPriceAndDownloadUrlToBooks < ActiveRecord::Migration[5.2]
  def change
    add_monetize :books, :price
    add_column   :books, :download_url, :text
  end
end

Run it for the development and test environments.

rails db:migrate && RAILS_ENV=test rails db:migrate

Output

== 20160614070126 AddPriceAndDownloadUrlToBooks: migrating ====================
-- add_column(:books, "price_cents", :integer, {:null=>false, :default=>0})
   -> 0.0089s
-- add_column(:books, "price_currency", :string, {:null=>false, :default=>"USD"})
   -> 0.0050s
-- add_column(:books, :download_url, :text)
   -> 0.0006s
== 20160614070126 AddPriceAndDownloadUrlToBooks: migrated (0.0150s) ===========

Next, let’s tell the Book model that it now has a price with the monetize method provided by money-rails.

class Book < ApplicationRecord
  include PgSearch
  multisearchable against: [:title, :subtitle, :description]

  monetize :price_cents

  # ...
end

Thanks to this method, we now have a bunch of helper methods to handle the price and its currency.

Before we proceed it would be nice if all our books actually had a price. Since this is going to be a one-time task, we can just do it manually with the rails console instead of creating a rake task.

Start the console…

rails c

and update all the books with a price of $2.99, or 299 cents in the format that money-rails expects.

Book.update_all(price_cents: 299)
Running via Spring preloader in process 9214
Loading development environment (Rails 5.x.x)
2.5.0 :001 > Book.update_all(price_cents: 299)
  SQL (21.9ms)  UPDATE "books" SET "price_cents" = 299
 => 997

Type exit to leave the console.

The Book model is now ready, but we still have to update its presenter to reflect the new attribute. Add price to the BookPresenter class. We don’t want to add the download_url attribute here, because it’s not meant for the client or user to see. This is something we will use later on to generate an expiring download link.

# app/presenters/book_presenter.rb
class BookPresenter < BasePresenter
  build_with    :id, :title, :subtitle, :isbn_10, :isbn_13, :description,
                :released_on, :publisher_id, :author_id, :created_at, :updated_at,
                :cover, :price_cents, :price_currency
  related_to    :publisher, :author
  sort_by       :id, :title, :released_on, :created_at, :updated_at, :price_cents,
                :price_currency
  filter_by     :id, :title, :isbn_10, :isbn_13, :released_on, :publisher_id,
                :author_id, :price_cents, :price_currency

  def cover
    path = @object.cover.url.to_s
    path[0] = '' if path[0] == '/'
    "#{root_url}#{path}"
  end

end

Now that we’ve assigned prices to the books, people need to be able to make purchases.

24.2. Buying Stuff with the Purchase Model

Every time someone buys a book, a purchase will be created. We are not going to use carts, users have to buy books individually. After a purchase is created, a service object will take care of verifying it with Stripe. We could use any other payment gateway, but Stripe is easy to operate and widely used; this makes it a very good choice for Alexandria.

24.2.1. The Purchase Model

The Purchase model needs the following fields.

  • id
  • book_id
  • user_id
  • price
  • idempotency_key
  • status
  • charge_id
  • error
  • created_at
  • updated_at

The idempotency_key is used to ensure that Stripe doesn’t bill the same purchase twice. This is a unique identifier for a purchase made by a user.

Generate this model, the migration file, and the tests with the command below.

rails g model Purchase book:references user:references price:money \
idempotency_key:string status:integer charge_id:string error:text

Output

Running via Spring preloader in process 24343
      invoke  active_record
      create    db/migrate/20160613030835_create_purchases.rb
      create    app/models/purchase.rb
      invoke    rspec
      create      spec/models/purchase_spec.rb
      invoke      factory_bot
      create        spec/factories/purchases.rb

First, we want indexes on the foreign keys book_id and user_id. To make those indexes, we are going to use a composite index for [:user_id, :book_id]. This index will let us perform fast queries on user_id or user_id AND book_id. We also want an index for book_id to get all the purchases for a specific book.

# db/migrate/TIMESTAMP_create_purchases.rb
class CreatePurchases < ActiveRecord::Migration[5.2]
  def change
    create_table :purchases do |t|
      t.references :book, foreign_key: true, index: true
      t.references :user, foreign_key: true
      t.monetize :price
      t.string :idempotency_key
      t.integer :status, default: 0
      t.string :charge_id
      t.string :token
      t.text :error, default: '{}', null: false

      t.timestamps
    end

    add_index :purchases, [:user_id, :book_id]
  end
end

Run this new migration.

rails db:migrate && RAILS_ENV=test rails db:migrate

Let’s update the purchases factory to include the idempotency_key.

# spec/factories/purchases.rb
FactoryBot.define do
  factory :purchase do
    book
    user
    idempotency_key { '12345' }
    token { '123' }
  end
end

Before we implement the Purchase model, let’s define our expectations for it. When a user buys a book, we want to generate a purchase that will link that book to the user. The purchase should be created right away with a pending status. Once we’ve sent the request to Stripe, the status can change to sent before becoming either confirmed or rejected. The idempotency key will be generated automatically before the record is saved. We also want to copy the price from the book and keep it in the purchase if the price of the book changes later (in case of a discount, price increase, etc).

In the tests, we obviously want a bunch of validations for the mandatory attributes. We also want to test two methods that we will implement that will be responsible for confirming or rejecting a purchase, depending on the outcome of the communication with Stripe.

# spec/models/purchase_spec.rb
require 'rails_helper'

RSpec.describe Purchase, :type => :model do
  let(:purchase) { build(:purchase) }
  let(:saved_purchase) { create(:purchase) }

  it { should validate_presence_of(:price_cents) }
  it { should validate_presence_of(:book) }
  it { should validate_presence_of(:user) }
  it { should validate_presence_of(:token) }

  it 'has a valid factory' do
   expect(purchase).to be_valid
  end

  it 'generates an access token before saving' do
    # Stub Time.now since it's used to generate the idempotency key
    @time_now = Time.parse("Apr 28 2016")
    allow(Time).to receive(:now).and_return(@time_now)

    purchase.save
    expect(purchase.idempotency_key).to eq(
      "#{@time_now}/#{purchase.user.id}/#{purchase.book.id}")
  end

  it 'adds the price before saving' do
    purchase.save
    expect(purchase.price).to eq(purchase.book.price)
  end

  describe '#confirm!' do
    before { saved_purchase.confirm!('123') }

    it 'saves the charge_id' do
      expect(saved_purchase.charge_id).to eq '123'
    end

    it 'confirms the purchase' do
      expect(saved_purchase.status).to eq 'confirmed'
    end
  end

  describe '#error!' do
    before { saved_purchase.error!({ 'something' => 'went wrong' }) }

    it 'registers an error' do
      expect(saved_purchase.error).to eq({ 'something' => 'went wrong' })
    end

    it 'rejects the purchase' do
      expect(saved_purchase.status).to eq 'rejected'
    end
  end
end

Run the tests. They should be failing for now.

rspec spec/models/purchase_spec.rb

Failure (RED)

...

Finished in 0.52071 seconds (files took 2.13 seconds to load)
11 examples, 10 failures

...

Let’s implement the Purchase model to make them pass.

# app/models/purchase.rb
class Purchase < ApplicationRecord
  belongs_to :book
  belongs_to :user

  before_save :generate_idempotency_key
  before_save :set_price

  store :error
  monetize :price_cents
  enum status: { created: 0, sent: 1, confirmed: 2, rejected: 3 }

  validates :price_cents, presence: true
  validates :book, presence: true
  validates :user, presence: true
  validates :token, presence: true

  def confirm!(charge_id)
    confirmed!
    update_column :charge_id, charge_id
  end

  def error!(error)
    rejected!
    update_column :error, error
  end

  private

  def generate_idempotency_key
    self.idempotency_key = "#{Time.now}/#{user.id}/#{book.id}"
  end

  def set_price
    self.price = book.price
  end
end

Run the tests to ensure that the Purchase model was correctly implemented.

rspec spec/models/purchase_spec.rb

Success (GREEN)

...

Purchase
  should validate that :price_cents cannot be empty/falsy
  should validate that :book cannot be empty/falsy
  should validate that :user cannot be empty/falsy
  should validate that :token cannot be empty/falsy
  has a valid factory
  generates an access token before saving
  adds the price before saving
  #confirm!
    saves the charge_id
    confirms the purchase
  #error!
    registers an error
    rejects the purchase

Finished in 0.57223 seconds (files took 2.35 seconds to load)
11 examples, 0 failures

Looks good… now, let’s move on to the presenter.

24.2.2. The PurchasePresenter Class

This is going to be quick. Create a new file for the PurchasePresenter class…

touch app/presenters/purchase_presenter.rb

and put the following in it. Once again, it’s a pretty generic presenter. We allow all the attributes to be used for the representations.

# app/presenters/purchase_presenter.rb
class PurchasePresenter < BasePresenter
  build_with    :id, :book_id, :user_id, :price_cents, :price_currency,
                :idempotency_key, :status, :charge_id, :error, :created_at,
                :updated_at
  related_to    :user, :book
  sort_by       :id, :book_id, :user_id, :price_cents, :price_currency, :status,
                :created_at, :updated_at
  filter_by     :id, :book_id, :user_id, :price_cents, :price_currency,
                :idempotency_key, :status, :charge_id, :error, :created_at,
                :updated_at
end

Purchases are ready! Now it’s time to integrate Stripe and create our first “connector” class.

Let’s run all the tests to ensure that we didn’t break anything.

rspec

Success (GREEN)

...

Finished in 7.48 seconds (files took 1.14 seconds to load)
297 examples, 0 failures

24.3. Integrating Stripe

The first thing we are going to do is sign up for a Stripe account. Once it’s done, we will have a test API key that we will use to call Stripe’s API.

24.3.1. Creating a Stripe Account

1. Create an Account

Head to Stripe’s Sign Up Page and enter your details to create an account. See Figure 1 for reference.

https://s3.amazonaws.com/devblast-mrwa-book/images/figures/24/01
Figure 1

2. Access “Developers > API Keys” and reveal your “Test key token”

Click on “Developers” in the sidebar, then “API Keys”. Checkout your API key in the “Secret Key” field. Copy/paste it somewhere, as we are going to need it soon. See Figure 2 for reference.

https://s3.amazonaws.com/devblast-mrwa-book/images/figures/24/02
Figure 2

3. Verify your Stripe account

You should have received an email from Stripe to confirm your email address. You will also need to add a phone number in order to send credit card numbers from Alexandria.

24.3.2. Adding Stripe to Alexandria

With our Stripe API key in the pocket, we can now configure our application to use Stripe for payments. Stripe provides a Ruby gem to use its API, so let’s add it to our Gemfile.

# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.5.0'

gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'
gem 'stripe'

# Hidden Code

Get it installed with bundle.

bundle install

We need a place to store the API key. Since this is sensitive information, we cannot put it anywhere in our versioned code. In production, we will be able to define a bunch of environment variables wherever we deploy the application. For development, let’s create a file that won’t be versioned where we can store our environment variables.

Create a new file in the config/ folder.

touch config/env.rb

Add this new file to the .gitignore file so it will be ignored by Git.

# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# If you find yourself ignoring temporary files generated by your text editor
# or operating system, you probably want to add a global ignore instead:
#   git config --global core.excludesfile '~/.gitignore_global'

# Ignore bundler config.
/.bundle

# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep

# Ignore uploaded files in development
/storage/*

.byebug_history

# Ignore master key for decrypting credentials and more.
/config/master.key

/public/uploads
/images
/config/env.rb

Finally, put the API key you got from Stripe in the env.rb file using the same name as in the code below (STRIPE_API_KEY).

# config/env.rb
ENV['STRIPE_API_KEY'] = 'YOUR_API_KEY'

For the last step, we need to to load the content of the env.rb file as early as possible. Let’s add this code:

env_variables = File.join('config', 'env.rb')
load(env_variables) if File.exists?(env_variables)

In the application.rb file.

# config/application.rb
require_relative 'boot'

require "rails"
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "rails/test_unit/railtie"

require 'carrierwave'
require 'carrierwave/orm/activerecord'

Bundler.require(*Rails.groups)

env_variables = File.join('config', 'env.rb')
load(env_variables) if File.exists?(env_variables)

module Alexandria
  class Application < Rails::Application
    config.load_defaults 5.2
    config.api_only = true
    config.filter_parameters += [:cover]
  end
end

Alright, this looks good to go. We’ve just signed up with Stripe and our application is configured to use the correct API key. Now, let’s prepare our tests implementation by adding a new gem: VCR.

24.3.3. Installing The VCR Gem

Stubbing calls to external services is a pain. Plus, the responses they provide can change which requires changing the way we stub. Instead, I prefer to use gems like VCR that will record HTTP interactions and replay them when needed. The first time, it will actually allow the test to make a request. Any future test run will use a cached and local version that VCR generated for us.

You will understand more as we progress through this section. For now, add the vcr gem to your Gemfile, in the :development, :test group.

# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.5.0'

gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'
gem 'stripe'

group :development, :test do
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'vcr'
end

group :development do
  gem 'listen', '>= 3.0.5', '< 3.2'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

group :test do
  gem 'shoulda-matchers'
  gem 'webmock'
  gem 'database_cleaner'
end

Install it with bundle.

bundle install

Before we can use it in our tests, we need to configure it in the rails_helper file. Simply append the code below at the end of the file. In it, we set the configuration for vcr, including where we want the cassettes to be stored. After that, we should be able to use the vcr gem!

# spec/rails_helper.rb

# Hidden Code

VCR.configure do |config|
  config.cassette_library_dir = 'spec/vcr_cassettes'
  config.hook_into :webmock
end

Want to give it a try? Let’s write the tests for the StripeConnector class then!

24.3.4. Connecting to Stripe with a Connector

First, what’s this connector class? Well, it’s the class that will be responsible for communicating with Stripe and that’s it. Instead of making external calls from a model or a controller, we encapsulate this logic in one dedicated class.

Generate the files for the Stripe connector.

mkdir app/connectors spec/connectors && \
  touch app/connectors/stripe_connector.rb \
        spec/connectors/stripe_connector_spec.rb

Here, we’ll make an exception and write the code before the tests. This is because the StripeConnector class has a bunch of new stuff that we need to go through. Afterwards, the tests will be much easier to understand.

This connector will be used in the purchases controller in the following way:

StripeConnector.new(purchase).charge

The purchase object should contain all the required information to charge the user. The charge method will let the stripe gem make the actual call to the Stripe API. Read the code and the comments below to get a better sense of it.

This connector was built based on the documentation of the Stripe API available here.

# app/connectors/stripe_connector.rb
class StripeConnector

  def initialize(purchase)
    @purchase = purchase

    # We need to set the API key if it hasn't been
    # set yet
    Stripe.api_key ||= ENV['STRIPE_API_KEY']
  end

  def charge
    @purchase.sent!
    create_charge
    @purchase
  end

  private

  def create_charge
    begin
      # Let's get some money!
      charge = Stripe::Charge.create(stripe_hash, {
        idempotency_key: @purchase.idempotency_key
      })

      # No error raised? Let's confirm the purchase.
      @purchase.confirm!(charge.id)
      charge
    rescue Stripe::CardError => e
      # If we get an error, we save it in the purchase.
      # The controller can then send it back to the client.
      body = e.json_body
      @purchase.error!(body[:error])
      body
    end
  end

  # Here we build the hash that will get submitted to
  # Stripe.
  def stripe_hash
    {
      amount: @purchase.price.fractional,
      currency: @purchase.price.currency.to_s,
      source: @purchase.token,
      metadata: { purchase_id: @purchase.id },
      description: description
    }
  end

  def description
    "Charge for #{@purchase.book.title} (Purchase ID #{@purchase.id})"
  end

end

Now let’s write some tests! We are going to test two contexts: with a valid card and with an invalid one. For a real application, we should probably write more tests and check more Stripe errors - but for Alexandria, this will do.

We are going to use the vcr gem to record the requests. This will allow us to re-run the tests super fast and as many times as we want without worrying about the network.

A test using vcr should include the following code:

VCR.use_cassette('cool_cassette_name') do
  # HTTP Interactions
end

Any HTTP request in this block will be executed the first time and cached for future reruns. You can always delete the VCR cassettes folder (spec/vcr_cassettes) to start fresh.

Here are the tests for the StripeConnector class.

# spec/connectors/stripe_connector_spec.rb
require 'rails_helper'

RSpec.describe StripeConnector do
  before(:all) { Stripe.api_key ||= ENV['STRIPE_API_KEY'] }

  let(:book) { create(:book, price_cents: 299) }
  let(:purchase) { create(:purchase, book: book) }

  def charge_with_token(purchase, card)
    token = Stripe::Token.create(card: card)
    purchase.update_column :token, token['id']
    StripeConnector.new(purchase).send(:create_charge)
  end

  def card(number)
    { number: number, exp_month: 6, exp_year: 2028, cvc: "314" }
  end

  context 'with valid card' do
    let(:valid_card) { card('4242424242424242') }

    it 'succeeds' do
      VCR.use_cassette('stripe/valid_card') do
        charge = charge_with_token(purchase, valid_card)

        expect(charge['status']).to eq 'succeeded'
        expect(purchase.reload.charge_id).to eq charge['id']
        expect(purchase.reload.status).to eq 'confirmed'
      end
    end
  end

  context 'with invalid card' do
    let(:invalid_card) { card('4000000000000002') }

    it 'declines the card' do
      VCR.use_cassette('stripe/invalid_card') do
        charge = charge_with_token(purchase, invalid_card)

        expect(charge[:error][:code]).to eq 'card_declined'
        expect(purchase.reload.error).to eq charge[:error].stringify_keys
        expect(purchase.reload.status).to eq 'rejected'
      end
    end
  end

end

Run the tests, just to be safe.

rspec spec/connectors/stripe_connector_spec.rb

Success (GREEN)

...

StripeConnector
  with valid card
    succeeds
  with invalid card
    declines the card

Finished in 0.3776 seconds (files took 2.6 seconds to load)
2 examples, 0 failures

Awesome! Before we attack the purchases controller, let’s define the purchase permissions with the PurchasePolicy class.

24.3.5. The Purchase Policy

We can now communicate with Stripe. The missing bit is an actual controller to call the StripeConnector from. Before we create that controller, let’s define the policies of purchases.

Create the needed files with this command.

touch app/policies/purchase_policy.rb \
      spec/policies/purchase_policy_spec.rb

You should be quite familiar with how policy tests have to be written by now, but this policy is a bit different. The index action will be used to send either all purchases (if the user is an admin), or only the purchases that this user made (if he is not an admin).

To achieve this, we will be using Pundit policy scopes - an example is available below. This code should be included inside the policy and we will then use it in our controller to ensure that users don’t get access to something they should not.

class Scope
  attr_reader :user, :scope

  def initialize(user, scope)
    @user  = user
    @scope = scope
  end

  def resolve
    if user.admin?
      scope.all
    else
      scope.where(user_id: user.id)
    end
  end
end

You should be quite familiar with how policy tests have to be written by now, but this policy is a bit different. The index action will be used to send either all purchases (if the user is an admin), or only the purchases that this user made (if he is not an admin).

We also added tests for index? and create? that everyone can access. The show action is open for admins but limited to purchases they made for regular users.

# spec/policies/purchase_policy_spec.rb
require 'rails_helper'

describe PurchasePolicy do
  subject { described_class }

  describe '.scope' do
    let(:admin) { create(:admin) }
    let(:user) { create(:user) }
    let(:rails_tuto) { create(:ruby_on_rails_tutorial) }
    let(:ruby_micro) { create(:ruby_microscope) }
    let(:purchase_admin) { create(:purchase, user: admin, book: ruby_micro) }
    let(:purchase_user) { create(:purchase, user: user, book: rails_tuto) }

    before { purchase_admin && purchase_user }

    context 'when admin' do
      let(:scope) { PurchasePolicy::Scope.new(admin, Purchase.all).resolve }

      it 'gets all the purchases' do
        expect(scope).to include(purchase_admin)
        expect(scope).to include(purchase_user)
      end
    end

    context 'when regular user' do
      let(:scope) { PurchasePolicy::Scope.new(user, Purchase.all).resolve }

      it 'gets all the purchases that belong to the user' do
        expect(scope).to_not include(purchase_admin)
        expect(scope).to include(purchase_user)
      end
    end
  end

  permissions :index?, :create? do
    it 'grants access' do
      expect(subject).to permit(User.new, Purchase)
    end
  end

  permissions :show? do
    context 'when regular user' do
      it 'denies access if the user and record owner are different' do
        expect(subject).not_to permit(User.new, Purchase.new)
      end

      it 'grants access if the user and record owner are the same' do
        user = User.new
        expect(subject).to permit(user, Purchase.new(user: user))
      end
    end

    context 'when admin' do
      it 'grants access' do
        expect(subject).to permit(build(:admin), Purchase.new)
      end
    end
  end
end

Now let’s implement the PurchasePolicy based on those expectations.

# app/policies/purchase_policy.rb
class PurchasePolicy < ApplicationPolicy

  def index?
    user
  end

  def show?
    user.admin? || record.user == user
  end

  def create?
    user
  end

  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user  = user
      @scope = scope
    end

    def resolve
      if user.admin?
        scope.all
      else
        scope.where(user_id: user.id)
      end
    end
  end

end

Run the tests to ensure that everything is working as expected.

rspec spec/policies/purchase_policy_spec.rb

Success (GREEN)

PurchasePolicy
  .scope
    when admin
      gets all the purchases
    when normal user
      gets all the purchases that belong to the user
  index? and create?
    grants access
  show?
    denies access if user is not admin and the user and record are different
    grants access if user is not admin and the user and record are the same
    grants access if the user is admin

Finished in 0.42125 seconds (files took 2.72 seconds to load)
6 examples, 0 failures

24.3.6. PurchasesController

Finally, the purchases controller! Create new files to hold its content and its tests.

touch app/controllers/purchases_controller.rb \
      spec/requests/purchases_spec.rb

We then need to add a new resources in the routes.rb file.

Add the following to the routes:

resources :purchases, only: [:index, :show, :create]

Here is the complete file for reference.

# config/routes.rb
Rails.application.routes.draw do
  scope :api do
    resources :books, except: :put
    resources :authors, except: :put
    resources :publishers, except: :put
    resources :users, except: :put

    resources :user_confirmations, only: :show, param: :confirmation_token
    resources :password_resets, only: [:show, :create, :update],
                                param: :reset_token

    resources :access_tokens, only: :create do
      delete '/', action: :destroy, on: :collection
    end

    resources :purchases, only: [:index, :show, :create]

    get '/search/:text', to: 'search#index'
  end

  root to: 'books#index'
end

The index Action

Let’s add the first action in our controller: index. This action will simply return a list of purchases and we will be using the policy_scope method from Pundit to ensure that users only get what they are allowed to.

We already tested the permissions in the purchase policy tests, so let’s just check that this action returns 200 with one purchase.

# spec/requests/purchases_spec.rb
require 'rails_helper'

RSpec.describe 'Purchases', type: :request do
  include_context 'Skip Auth'

  before(:all) { Stripe.api_key ||= ENV['STRIPE_API_KEY'] }

  let(:book) { create(:ruby_on_rails_tutorial, price_cents: 299) }
  let(:purchase) { create(:purchase, book: book) }

  describe 'GET /api/purchases' do
    before do
      purchase
      get '/api/purchases'
    end

    it 'gets HTTP status 200' do
      expect(response.status).to eq 200
    end

    it 'receives the only purchase in the db' do
      expect(json_body['data'].size).to eq 1
      expect(json_body['data'].first['id']).to eq purchase.id
    end
  end # describe 'GET /api/purchases' end
end

If you run the tests now, they’ll be failing.

rspec spec/requests/purchases_spec.rb

Failure (RED)

...

Finished in 0.34778 seconds (files took 2.48 seconds to load)
2 examples, 2 failures

...

Let’s fix them by creating the PurchasesController class and implementing the index action. We use the orchestrate_query method in the same way than with our other controllers. This time however, we pass the scope generated by Pundit with policy_scope(Purchase) instead of Purchase.all.

# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
  before_action :authenticate_user
  before_action :authorize_actions

  def index
    purchases = orchestrate_query(policy_scope(Purchase))
    render serialize(purchases)
  end
end

Let’s run the tests again.

rspec spec/requests/purchases_spec.rb

Success (GREEN)

...

Purchases
  GET /api/purchases
    gets HTTP status 200
    receives 1 purchase

Finished in 0.38649 seconds (files took 3.58 seconds to load)
2 examples, 0 failures

Great, everything is working. Let’s proceed with another simple action: show.

The show Action

This action is going to be exactly like in our other controllers. First, we’ll write some basic tests to ensure that this action returns 200 and the expected purchase.

# spec/requests/purchases_spec.rb
require 'rails_helper'

RSpec.describe 'Purchases', type: :request do
  # Hidden Code

  describe 'GET /api/purchases' # Hidden Code

  describe 'GET /api/purchases/:id' do

    context 'with existing resource' do
      before { get "/api/purchases/#{purchase.id}" }

      it 'gets HTTP status 200' do
        expect(response.status).to eq 200
      end

      it 'receives the purchase as JSON' do
        expected = { data: PurchasePresenter.new(purchase, {}).fields.embeds }
        expect(response.body).to eq(expected.to_json)
      end
    end

    context 'with nonexistent resource' do
      it 'gets HTTP status 404' do
        get '/api/purchases/2314323'
        expect(response.status).to eq 404
      end
    end
  end # describe 'GET /purchases/:id' end
end

Run the tests to see them fail.

rspec spec/requests/purchases_spec.rb

Failure (RED)

...

Finished in 0.47026 seconds (files took 2.37 seconds to load)
5 examples, 3 failures

Finally, let’s implement the show action with… one line. We also need to implement the purchase method that will load the current purchase for us.

# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
  before_action :authenticate_user
  before_action :authorize_actions

  def index
    purchases = orchestrate_query(policy_scope(Purchase))
    render serialize(purchases)
  end

  def show
    render serialize(purchase)
  end

  private

  def purchase
    @purchase ||= params[:id] ? Purchase.find_by!(id: params[:id]) :
                                Purchase.new(purchase_params)
  end
  alias_method :resource, :purchase

end

How are the tests doing now?

rspec spec/requests/purchases_spec.rb

Success (GREEN)

...

Purchases
  GET /api/purchases
    gets HTTP status 200
    receives 1 purchase
  GET /api/purchases/:id
    with existing resource
      gets HTTP status 200
      receives the purchase as JSON
    with nonexistent resource
      gets HTTP status 404

Finished in 0.52174 seconds (files took 2.39 seconds to load)
5 examples, 0 failures

Neat!

The create Action

The create action is the most complex one. This is where we will instantiate the StripeConnector class. Go through the tests below; I believe the test names are explicit enough to be self-descriptive.

# spec/requests/purchases_spec.rb
require 'rails_helper'

RSpec.describe 'Purchases', type: :request do
  # Hidden Code

  describe 'GET /api/purchases' # Hidden Code
  describe 'GET /api/purchases/:id' # Hidden Code

  describe 'POST /api/purchases' do
    context 'with valid parameters' do
      let(:card) do
        { number: '4242424242424242', exp_month: 6, exp_year: 2028, cvc: "314" }
      end
      let(:token) { Stripe::Token.create(card: card)['id'] }
      let(:params) { attributes_for(:purchase, book_id: book.id, token: token) }

      it 'gets HTTP status 201' do
        VCR.use_cassette('/api/purchases/valid_params') do
          post '/api/purchases', params: { data: params }
          expect(response.status).to eq 201
        end
      end

      it 'returns the newly created resource' do
        VCR.use_cassette('/api/purchases/valid_params') do
          post '/api/purchases', params: { data: params }
          expect(json_body['data']['book_id']).to eq book.id
        end
      end

      it 'adds a record in the database' do
        VCR.use_cassette('/api/purchases/valid_params') do
          post '/api/purchases', params: { data: params }
          expect(Purchase.count).to eq 1
        end
      end

      it 'returns the new resource location in the Location header' do
        VCR.use_cassette('/api/purchases/valid_params') do
          post '/api/purchases', params: { data: params }
          expect(response.headers['Location']).to eq(
            "http://www.example.com/api/purchases/#{Purchase.first.id}"
          )
        end
      end
    end

    context 'with invalid parameters' do
      let(:params) { attributes_for(:purchase, token: '') }
      before { post '/api/purchases', params: { data: params } }

      it 'gets HTTP status 422' do
        expect(response.status).to eq 422
      end

      it 'receives an error details' do
        expect(json_body['error']['invalid_params']).to eq(
          {"book"=>["must exist", "can't be blank"], "token"=>["can't be blank"]}
        )
      end

      it 'does not add a record in the database' do
        expect(Purchase.count).to eq 0
      end
    end # context 'with invalid parameters'
  end # describe 'POST /purchases'
end

To make those tests pass, we need to make a little modification in the application controller. We need the unprocessable_entity! method to be able to optionally receive the errors that will be returned to the client.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # Hidden Code

  protected

  def builder_error # Hidden Code

  def unprocessable_entity!(resource, errors = nil)
    render status: :unprocessable_entity, json: {
      error: {
        message: "Invalid parameters for resource #{resource.class}.",
        invalid_params: errors || resource.errors
      }
    }
  end

  def orchestrate_query # Hidden Code
  def serialize # Hidden Code
  def resource_not_found # Hidden Code
end

With that done, let’s implement the create action. Note that we will be calling the Stripe API in the request flow, which is far from ideal. This will block the request until Stripe has finished processing our request and replied. A better way of doing it would be to run it in the background with ActiveJob. Unfortunately, this is out of the scope of this book, so we will have to count on the client to handle this blocking request.

The code below contains comments in the create action.

# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
  before_action :authenticate_user
  before_action :authorize_actions

  def index
    purchases = orchestrate_query(policy_scope(Purchase))
    render serialize(purchases)
  end

  def show
    render serialize(purchase)
  end

  def create
    # The current_user is making the purchase so let's
    # assign it to the purchase object
    purchase.user = current_user

    if purchase.save
      # Let's get some money!
      completed_purchase = StripeConnector.new(purchase).charge

      # Did something go wrong?
      if completed_purchase.error.any?
        unprocessable_entity!(completed_purchase, purchase.error)
      else
        # Let's return the purchase to the client
        render serialize(completed_purchase).merge({
          status: :created,
          location: completed_purchase
        })
      end
    else
      unprocessable_entity!(purchase)
    end
  end

  private

  def purchase
    @purchase ||= params[:id] ? Purchase.find_by!(id: params[:id]) :
                                Purchase.new(purchase_params)
  end
  alias_method :resource, :purchase

  def purchase_params
    params.require(:data).permit(:book_id, :token)
  end

end

Run the tests to ensure that we met all our expectations.

rspec spec/requests/purchases_spec.rb

Success (GREEN)

...

Purchases
  GET /api/purchases
    gets HTTP status 200
    receives 1 purchase
  GET /api/purchases/:id
    with existing resource
      gets HTTP status 200
      receives the purchase as JSON
    with nonexistent resource
      gets HTTP status 404
  POST /api/purchases
    with valid parameters
      gets HTTP status 201
      returns the newly created resource
      adds a record in the database
      returns the new resource location in the Location header
    with invalid parameters
      gets HTTP status 422
      receives an error details
      does not add a record in the database

Finished in 1.15 seconds (files took 2.45 seconds to load)
12 examples, 0 failures

Awesome! This finalizes the purchase flow.

24.4. Letting The User Download a Book

The following section consists of a theoretical and a practical part. We won’t have a completely functional download system since we have no books and I don’t want to take you through the Amazon S3 setup here.

The final step to Alexandria would be to allow users to download the books they bought. To do this, we need to give a link to the client where the purchased book can be downloaded. We could also send an email to the user with every purchase.

To do those things, we need a downloads controller that will generate and send back the download link for a specific book.

Let’s add a few more files to put this logic.

touch app/controllers/downloads_controller.rb \
      spec/requests/downloads_spec.rb \
      app/policies/download_policy.rb \
      spec/policies/download_policy_spec.rb

Add a new route, downloads, in the books resources block. With this, we will be able to access /books/1/download.

# config/routes.rb
Rails.application.routes.draw do
  scope :api do
    resources :books, except: :put do
       get :download, to: 'downloads#show'
     end

    resources :authors, except: :put
    resources :publishers, except: :put
    resources :users, except: :put

    resources :user_confirmations, only: :show, param: :confirmation_token
    resources :password_resets, only: [:show, :create, :update],
                                param: :reset_token

    resources :access_tokens, only: :create do
      delete '/', action: :destroy, on: :collection
    end

    resources :purchases, only: [:index, :show, :create]

    get '/search/:text', to: 'search#index'
  end

  root to: 'books#index'
end

To check if the user can download this book, we need to have the list of all the books a specific user has bought. We can do this using some has_many relationships.

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_many :access_tokens
  has_many :purchases
  has_many :books, through: :purchases

  # Hidden Code

The download policy tests are pretty straightforward, since there is only one action (show) which will return a download link.

# spec/policies/download_policy_spec.rb
require 'rails_helper'

describe DownloadPolicy do
  subject { described_class }

  permissions :show? do
    context 'when admin' do
      it 'grants access' do
        expect(subject).to permit(build(:admin), Purchase.new)
      end
    end

    context 'when not admin' do
      it 'denies access if the user did not buy the book' do
        user = create(:user)
        expect(subject).not_to permit(user, create(:book))
      end

      it 'grants access if the user has bought the book' do
        user = create(:user)
        book = create(:book)
        create(:purchase, user: user, book: book)
        expect(subject).to permit(user, book)
      end
    end
  end
end

The download policy in all its splendor.

# app/policies/download_policy.rb
class DownloadPolicy < ApplicationPolicy

  def show?
    user.admin? || user.books.pluck(:id).include?(record.id)
  end

end

Run the tests, just to be safe.

rspec spec/policies/download_policy_spec.rb

Success (GREEN)

DownloadPolicy
  show?
    when admin
      grants access
    when not admin
      denies access if the user didn't buy the book
      grants access if the user has bought the book

Finished in 0.28774 seconds (files took 2.89 seconds to load)
3 examples, 0 failures

Now, let’s add the downloads controller.

# app/controllers/downloads_controller.rb
class DownloadsController < ApplicationController
  before_action :authenticate_user

  def show
    authorize(book)
    render status: 204, location: book.download_url
  end

  private

  def book
    @book ||= Book.find_by!(id: params[:book_id])
  end

end

And that’s it. The client can do whatever it wants with this download URL.

Note that this is approach could be improved by sending links that expires after a while. If we were using Amazon S3, we could use a method that connects to S3 and generates a link valid for 10 minutes. We would also need to store the book filename in that bucket instead of the download URL.

Here are some tests for the downloads controller.

# spec/requests/downloads_spec.rb
require 'rails_helper'

RSpec.describe 'Access Tokens', type: :request do
  include_context 'Skip Auth'

  let(:book) { create(:book, download_url: 'http://example.com') }

  describe 'GET /api/books/:book_id/download' do

    context 'with an existing book' do

      before { get "/api/books/#{book.id}/download" }

      it 'returns 204' do
        expect(response.status).to eq 204
      end

      it 'returns the download url in the Location header' do
        expect(response.headers['Location']).to eq 'http://example.com'
      end

    end

    context 'with nonexistent book' do
      it 'returns 404' do
        get '/api/books/123/download'
        expect(response.status).to eq 404
      end
    end
  end
end

24.5. Pushing Our Changes

Run all the tests to ensure that everything is working.

rspec

Success (GREEN)

...

Finished in 13.88 seconds (files took 4.93 seconds to load)
323 examples, 0 failures

Let’s push the changes.

git status

Output

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   .gitignore
	modified:   Gemfile
	modified:   Gemfile.lock
	modified:   app/controllers/application_controller.rb
	modified:   app/models/book.rb
	modified:   app/models/user.rb
	modified:   app/presenters/book_presenter.rb
	modified:   config/application.rb
	modified:   config/routes.rb
	modified:   db/schema.rb
	modified:   spec/rails_helper.rb

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	app/connectors/
	app/controllers/downloads_controller.rb
	app/controllers/purchases_controller.rb
	app/models/purchase.rb
	app/policies/download_policy.rb
	app/policies/purchase_policy.rb
	app/presenters/purchase_presenter.rb
	config/initializers/money.rb
	db/migrate/20160614070126_add_price_and_download_url_to_books.rb
	db/migrate/20160614070533_create_purchases.rb
	spec/connectors/
	spec/factories/purchases.rb
	spec/models/purchase_spec.rb
	spec/policies/download_policy_spec.rb
	spec/policies/purchase_policy_spec.rb
	spec/requests/downloads_spec.rb
	spec/requests/purchases_spec.rb
	spec/vcr_cassettes/

no changes added to commit (use "git add" and/or "git commit -a")

Stage them.

git add .

Commit the changes.

git commit -m "Implement purchases"

Output

[master 141c88f] Implement purchases
 33 files changed, 1275 insertions(+), 26 deletions(-)
 create mode 100644 app/connectors/stripe_connector.rb
 create mode 100644 app/controllers/downloads_controller.rb
 create mode 100644 app/controllers/purchases_controller.rb
 create mode 100644 app/models/purchase.rb
 create mode 100644 app/policies/download_policy.rb
 create mode 100644 app/policies/purchase_policy.rb
 create mode 100644 app/presenters/purchase_presenter.rb
 create mode 100644 config/initializers/money.rb
 create mode 100644 db/migrate/20160614070126_add_price_and_download_url_to_books.rb
 create mode 100644 db/migrate/20160614070533_create_purchases.rb
 create mode 100644 spec/connectors/stripe_connector_spec.rb
 create mode 100644 spec/factories/purchases.rb
 create mode 100644 spec/models/purchase_spec.rb
 create mode 100644 spec/policies/download_policy_spec.rb
 create mode 100644 spec/policies/purchase_policy_spec.rb
 create mode 100644 spec/requests/downloads_spec.rb
 create mode 100644 spec/requests/purchases_spec.rb
 create mode 100644 spec/vcr_cassettes/api/purchases/valid_params.yml
 create mode 100644 spec/vcr_cassettes/stripe/invalid_card.yml
 create mode 100644 spec/vcr_cassettes/stripe/valid_card.yml

Push to GitHub.

git push origin master

24.6. Wrap Up

This chapter was the last big milestone for Alexandria. In the next chapters, we will work on optimizations and improvements before deploying it live. In the next module, we will come back to it and see why Alexandria is not RESTful, and how we can fix it.