Notifying customers of the outcome of their transaction is the important final step of a purchase. Typically, we need to send the customer an invoice. For our Snow Globe Theater application, we also probably need to send the customer an actual ticket, most likely with some kind of identification or bar code so that the use of the ticket can be tracked. And if something has gone wrong, we need to tell customers that they have not purchased the tickets they thought were purchased.
Our application requires two important types of notifications:
Customers need to be notified of the success or failure of their purchases. This is especially true now that the purchase is in a background job and customers are not immediately taken to a status page.
Administrators need to be notified of exceptional conditions or anything strange that happens.
We are going to manage these notifications with a combination of Rails ActiveJob exception handing, Rails mailers, and a third-party exception notifier tool.
The customer notification is managed with Rails mailers, which are well described in other places.[39] I did want to quickly mention how to integrate the mailer with our code. First, we create the mailer with a Rails generator:
| $ rails generate mailer payment_mailer |
Then we can just put a couple of placeholder mailing methods in place:
| class PaymentMailer < ApplicationMailer |
| |
| def notify_success(payment) |
| end |
| |
| def notify_failure(payment) |
| end |
| |
| end |
In a full application, we’d have logic to actually send these emails to users.
Then we integrate the notification when we have successes and failures, for example, on a Stripe purchase:
| def run |
| Payment.transaction do |
| result = charge |
| result ? on_success : on_failure |
| end |
| end |
| |
| def on_success |
| PaymentMailer.notify_success(payment).deliver_later |
| end |
| |
| def on_failure |
| unpurchase_tickets |
| PaymentMailer.notify_failure(payment).deliver_later |
| end |
For administrative notifications, I like Rollbar[40] as a third-party exception notifier and aggregator. It’s free for up to 5,000 events handled per month, and then has a series of paid plans. (I admit it feels weird to be recommending a commercial service, but we’ve spent the last several chapters talking about Stripe, so I think I’ll cope.)
Rollbar is pretty easy to set up. Go to the Rollbar home page and create an account. As part of creating a new account you’ll be asked to start a first project. Select “Rails” as the framework, and you’ll be given a page with a server-side access token. The post_server_token is the one you want.
Now we add Rollbar to our project, first as a gem:
| gem "rollbar" |
Then we do a bundle install, and a rails generate using the access token provided by Rollbar:
| $ bundle install |
| $ rails generate rollbar ACCESS_TOKEN |
The generator creates a config/initializers/rollbar.rb file, which includes your access token. Technically the access token is not really secret, but it seems better to me to put it in the secrets.yml file.
I normally turn off Rollbar in development mode by tweaking the rollbar.rb file:
| config.access_token = |
| Rails.application.secrets.rollbar_server_side_access_token |
| |
| config.enabled = false if Rails.env.test? || Rails.env.development? |
At this point, Rollbar is set up and any unhandled error from a Rails controller in staging or production is sent to Rollbar, where it goes on a dashboard, and you can choose to have it emailed, sent via Slack, or any of a jillion other integrations. We also need to integrate Rollbar with our ActiveJobs. In Rails 5, we can do this by including the following line in the project parent ApplicationJob class.
| class ApplicationJob < ActiveJob::Base |
| |
| include Rollbar::ActiveJob |
| |
| end |
Then it’s a question of walking through all the exit points of our jobs and deciding who we want to notify. The setup job has three unsuccessful ways out: validation failure, preexisting purchase object, and database failure. The customer probably needs to know about a validation failure, since it prevents the purchase from going through. And you will probably want to be notified administratively, because the failure could indicate either a bug or security hole in the application. A database failure should cause the job to be re-queued, so we don’t need to tell the customer a final result yet, but we do want to notify the administrators because the failure might indicate a bug or system outage.
One way to trigger these notification responses is to cause an exception to be raised when they occur:
| def run |
| Payment.transaction do |
| raise PreExistingPaymentException.new(purchase) if existing_payment |
| unless pre_purchase_valid? |
| raise ChargeSetupValidityException.new( |
| user: user, |
| expected_purchase_cents: purchase_amount.to_i, |
| expected_ticket_ids: expected_ticket_ids) |
| end |
| update_tickets |
| create_payment |
| on_success |
| end |
| rescue |
| on_failure |
| raise |
| end |
We call the exceptions with enough information to investigate later, if needed.
The exception classes themselves are minimal. One for the validity exception:
| class ChargeSetupValidityException < StandardError |
| |
| attr_accessor :message, :user, :expected_purchase_cents, :expected_ticket_ids |
| |
| def initialize(message = nil, |
| user:, expected_purchase_cents:, expected_ticket_ids:) |
| super(message) |
| @user = user |
| @expected_purchase_cents = expected_purchase_cents |
| @expected_ticket_ids = expected_ticket_ids |
| end |
| |
| end |
And another for the pre-existing payment one:
| class PreExistingPaymentException < StandardError |
| |
| attr_accessor :payment |
| |
| def initialize(payment, message = nil) |
| super(message) |
| @purchase = payment |
| end |
| |
| end |
And then we use the rescue_from method of ActiveJob to catch the exceptions inside the ActiveJob itself:
| rescue_from(ChargeSetupValidityException) do |exception| |
| PaymentMailer.notify_failure(exception).deliver_later |
| Rollbar.error(exception) |
| end |
| |
| rescue_from(PreExistingPaymentException) do |exception| |
| Rollbar.error(exception) |
| end |
The rescue_from method takes an exception class and a block as an argument. If an exception of that class is raised, the block is invoked, and the exception is, by default, swallowed, meaning that the job is not re-queued. If we want the job to be re-queued, we can re-raise the exception (or any exception) from within the block.
Inside these blocks, we’re explicitly calling Rollbar.error(exception) to send the exception to Rollbar (because we’re handling the exception, Rollbar won’t see it automatically). And in the case of the validity exception, we’re also notifying the customer via a mailer and deliver_later, which puts the mail job in the ActiveJob queue.
In the third exceptional exit point, the database failure, the exception is not caught, so Rollbar gets it automatically, and the job goes back in the queue. Then we only need to worry about making sure we know to look at a bug if one arises, which we can do either by checking Rollbar or by preventing Delayed Job from deleting jobs after their last failed attempt.
Let’s see what happens if we add a delayed job initializer file:
| Delayed::Worker.destroy_failed_jobs = false |
The Delayed Job sets a failed_at parameter when a job has failed the maximum number of times. We can then look at those jobs with a different automated task to notify the customer that his purchase has failed.
The PurchasesCartCharge job needs to notify the customer on success or failure, which we can do via a mailer. It also can invoke the PreExistingPaymentException if the order has already been charged. If the Stripe API fails, we can either capture those Stripe exceptions with a respond_with in the job, or just allow the default process to rerun the order and check for jobs that fail out of the delayed job queue.
We can similarly use that PreExistingPaymentException in the executing Stripe job rather than use a sentinel value:
| def charge |
| raise PreExistingPaymentException if payment.response_id.present? |
| @stripe_charge = StripeCharge.new(token: stripe_token, payment: payment) |
| @stripe_charge.charge |
| payment.update!(@stripe_charge.payment_attributes) |
| payment.succeeded? |
| end |
Raising the exception prevents the on_failure handler from being executed and reverting the tickets, but we can still configure the ActiveJob to notify Rollbar:
| class ExecutesStripePurchaseJob < ActiveJob::Base |
| |
| queue_as :default |
| |
| rescue_from(PreExistingPaymentException) do |exception| |
| Rollbar.error(exception) |
| end |
| |
| def perform(payment, stripe_token) |
| charge_action = ExecutesStripePurchase.new(payment, stripe_token) |
| charge_action.run |
| end |
| |
| end |