Stripe Recurring Billing Part 7 : Dealing with Exceptions

Objective


To learn how to handle exceptions and make the code robust.

Discussion


Distinguish between business exceptions and technical. Create a specific exception so that the client can handle the situation on its own terms.

Tip by Dan Bergh Johnsson from the book 97 Things a Programmer Should Know

The application should separate data access logic from business logic. We don't want our application to be dependent on the external systems such as database, API, messaging bus etc. We encapsulate the low-lever data manipulation logic in a data access layer. This is isolated from the domain model. External systems have their own errors and exceptions. These errors should not propagate to other layers, if it does, the other layers will become specific to the implementation details and will make switching to a different data source difficult. Therefore we lose flexibility. To prevent this, we can translate the technical exceptions to business exceptions in the data access layer.

We can also have a data mapper layer that maps the persistence objects to domain objects. Thus the data mapper layer protects the application from the data formats dictated by the persistence layer. It also makes the data access layer agnostic to how the domain model works. So, the application logic and data access logic are separated from each other. This means we have achieved Separation of Concerns. This also means that these layers can be developed in parallel by different teams without depending on each other. We can allocate work according to the strength in skills of the developers. Changes in one layer does not affect any changes to the other layers. Testing will also be easier since we test each layer in isolation. As long as we program to an interface that defines the communication between layers, changes do not ripple into other layers of the system. External systems are wrapped in a well defined interface.

Steps


Step 1

The credit card 4000000000000069 can be used to simulate a decline due to expired card. The Stripe API will throw a Stripe::CardError exception for this case. We must notify the user about the reason for the decline so that they can use a different card to subscribe to our SaaS application. The business exception in this case is CreditCardDeclined with the message 'Your card has expired'.

Create a directory named exceptions/striped under app directory. Create application specific exception class, under this directory: credit_card_declined.rb:

module Striped
  class  CreditCardDeclined < Exception
  end
end

Step 2

Add rescue clause to handle Stripe::CardError in the create_subscription method of the StripeGateway class as follows:

def create_subscription(email, stripe_token, plan_id)
  begin
    customer = Stripe::Customer.create(description: email, card: stripe_token, plan: plan_id)
  rescue Stripe::CardError => e
    # Since it's a decline, Stripe::CardError will be caught
    body = e.json_body
    err  = body[:error]

    @logger.error "Status is: #{e.http_status}"
    @logger.error "Type is: #{err[:type]}"
    @logger.error "Code is: #{err[:code]}"
    # param is '' in this case
    @logger.error "Param is: #{err[:param]}"
    @logger.error "Message is: #{err[:message]}" 

    raise Striped::CreditCardDeclined.new(err[:message])     
  rescue Stripe::InvalidRequestError => e
    @logger.error "Create subscription failed due to Stripe::InvalidRequestError : #{e.message}"
  rescue Exception => ex
    @logger.error "Create subscription failed due to : #{ex.message}"  
  end
end

The StripeGateway class now translates the external Stripe specific exception Stripe::CardError to our application specific Striped::CreditCardDeclined exception.

Step 3

Change the SubscriptionsController#create method to handle the credit card decline.

def create
  begin
    @subscription = Actors::Customer::UseCases.subscribe_to_a_plan('current_user.email', 
                                                                   params[:stripeToken], 
                                                                   params[:plan_name], 
                                                                   logger)    
  rescue Striped::CreditCardDeclined => e
    @error_message = e.message
    @subscription = Subscription.new

    render :new
  end
end

Our application now depends on our application specific exception class, Striped::CreditCardDeclined.

Step 4

Change the app/views/subscriptions/new.html.erb to display the error message above the credit card number text field:

<% if @error_message %>
    <%= @error_message %>
<% end %>

Step 5

Using stripe mock to simulate a declined credit card gets very messy. So we will use Capybara integration test to test the credit card decline. Let's add a new scenario to spec/features/subscribe_spec.rb:

scenario 'Customer subscription credit card decline', js: true do
  visit "/pricing"
  click_link 'Gold'
  fill_in "Card Number", with: '4000000000000069'
  page.select '10', from: "card_month"
  page.select '2029', from: 'card_year'

  click_button 'Subscribe Me'
  expect(page).to have_content('Your card has expired.')
end

All tests will pass.

Step 6

Create credit_card_exception.rb in app/exceptions/striped directory.

module Striped
  class  CreditCardException < Exception
  end
end

Step 7

Let's add more tests to make the controller robust. Here is the complete source code for subscriptions_controller_spec.rb.

require 'rails_helper'

describe SubscriptionsController do

  it 'should delegate creating stripe customer to stripe gateway' do    
    allow(Actors::Customer::UseCases).to receive(:subscribe_to_a_plan).with('current_user.email', '1', 'gold', Rails.logger)

    post :create, { stripeToken: '1', plan_name: 'gold'}
  end

  it 'should initialize plan name' do
    get :new, {plan_name: 'gold'}

    expect(assigns(:plan_name)).to eq('gold')
  end

  it 'should handle credit card exception' do
    post :create, {stripeToken: 'bogus', plan_name: 'junk' }

    expect(assigns(:error_message)).to eq('Subscription failed. We have been notified about this problem.')
  end

  it 'should render the form when credit card exception occurs' do
    post :create, {stripeToken: 'bogus', plan_name: 'junk' }

    expect(response).to render_template(:new)
  end

  it 'should raise credit card exception when authentication error occurs' do
    Stripe.api_key = 'junk'

    post :create, {stripeToken: 'bogus', plan_name: 'junk' }

    expect(assigns(:error_message)).to eq('Subscription failed. We have been notified about this problem.')
  end
end

Step 8

Here is the complete source code for subscriptions_controller.rb.

def create
  begin
    @subscription = Actors::Customer::UseCases.subscribe_to_a_plan('current_user.email', 
                                                                   params[:stripeToken], 
                                                                   params[:plan_name], 
                                                                   logger)    
  rescue Striped::CreditCardDeclined => e
    redisplay_form(e.message)
  rescue Striped::CreditCardException, Exception => e
    redisplay_form("Subscription failed. We have been notified about this problem.")
  end
end

Step 9

Here is the complete source code for stripe_gateway_spec.rb.

require 'stripe_mock'
require 'rails_helper'
require 'stripe'

describe StripeGateway do
  context 'success scenario' do
    let(:stripe_helper) { StripeMock.create_test_helper }
    let(:plan) { stripe_helper.create_plan(:id => 'gold', :amount => 1500) }

    before { StripeMock.start }
    after { StripeMock.stop }

    it 'customer should be subscribed to gold plan' do
      sg = StripeGateway.new(Rails.logger)
      customer = sg.create_subscription('test-email', stripe_helper.generate_card_token, plan.id)

      expect(customer.id).to eq('test_cus_3')
    end
  end

  it 'should raise credit card exception when Stripe::InvalidRequestError occurs' do
    sg = StripeGateway.new(Rails.logger)

    expect do
      customer = sg.create_subscription('fake', 'bogus', 'junk')
    end.to raise_error Striped::CreditCardException

  end

end

Step 10

Here is the complete source code for stripe_gateway.rb.

class StripeGateway
  def initialize(logger)
    @logger = logger
  end

  def create_subscription(email, stripe_token, plan_id)
    begin
      customer = Stripe::Customer.create(description: email, card: stripe_token, plan: plan_id)
    rescue Stripe::CardError => e
      # Since it's a decline, Stripe::CardError will be caught
      body = e.json_body
      err  = body[:error]

      @logger.error "Status is: #{e.http_status}"
      @logger.error "Type is: #{err[:type]}"
      @logger.error "Code is: #{err[:code]}"
      # param is '' in this case
      @logger.error "Param is: #{err[:param]}"
      @logger.error "Message is: #{err[:message]}" 

      raise Striped::CreditCardDeclined.new(err[:message])     
    rescue Stripe::InvalidRequestError => e
      @logger.error "Create subscription failed due to Stripe::InvalidRequestError : #{e.message}"

      raise Striped::CreditCardException.new(e.message)
    rescue Stripe::AuthenticationError => e
      @logger.error "Authentication with Stripe's API failed"
      @logger.error "(maybe you changed API keys recently)"

      raise Striped::CreditCardException.new(e.message)
    rescue Stripe::APIConnectionError => e
      @logger.error "Network communication with Stripe failed"

      raise Striped::CreditCardException.new(e.message)
    rescue Stripe::StripeError => e
      @logger.error "Display a very generic error to the user, and maybe send yourself an email"

      raise Striped::CreditCardException.new(e.message)
    rescue Exception => ex
      @logger.error "Create subscription failed due to : #{ex.message}"  

      raise
    end
  end  
end

You can download the entire source code for this article using the commit hash 31652ff from : git@bitbucket.org:bparanj/striped.git.

Summary


In this article you learned how to make the code robust by defining application specific exceptions to handle edge cases. You also learned not to depend upon technical exceptions and how to use application specific exceptions.


Related Articles


Software Compatibility Best Practices

I spoke to some of the most talented and experienced software developers. I have created a guide that is filled with valuable insights and actionable ideas to boost developer productivity.

You will gain a better understanding of what's working well for other developers and how they address the software compatibility problems.

Get the Guide Now