Recently we have been working on a project to create a payment system using Stripe. When you are processing payments you often have a process that goes through a number of steps. For example, if you are processing a payment for a online service that you are signing up for you might need to create a local user account, register the user with stripe, process a charge and finally update the local user account to put them in a paid state. Along the way you also will have a number of failure scenarios that could break things.
At first glance you might try solving the problem procedurally using a fat model skinny controller approach. Your code might look something like this.
class PurchaseController < ApplicationController
def create
@new_purchase = Purchase.new params
@purchase_result = @new_purchase.process_payment
end
end
class User < ActiveRecord::Base
has_many :purchases
end
class Purchase < ActiveRecord::Base
belongs_to :user
attr_accessor :params
def initialize(init_params)
params = init_params
end
def process_payment
user.stripe_customer_id = create_customer[‘id’]
execute_charge
end
def execute_charge
charge = Stripe::Charge.create(
amount: price_in_cents,
currency: 'usd',
customer: user.stripe_customer_id,
description: description
)
stripe_charge_id = charge["id"]
amount = charge["amount"]
stripe_created_at = Time.at(charge["created"].to_i)
end
def create_customer
response = Stripe::Customer.create(
email: params.email,
card: params.token,
description: params.id
)
end
def set_customer_information
c = Stripe::Customer.retrieve(user.stripe_customer_id)
user.card_fingerprint = c["active_card"]["fingerprint"]
user.card_type = c["active_card"]["type"]
user.card_last4 = c["active_card"]["last4"]
user.card_exp_month = c["active_card"]["exp_month"].to_i
user.card_exp_year = c["active_card"]["exp_year"].to_i
end
end
The problem is that you have a class that violates rule of single responsibility. Your model is persisting information about a purchase, and processing payments on Stripe. We could easily refactor this code to move the stripe charge activities to another class.
class PurchaseController < ApplicationController
def create
@new_purchase = Purchase.new params
@purchase_result = @new_purchase.process_payment
end
end
class User < ActiveRecord::Base
has_many :purchases
end
class Purchase < ActiveRecord::Base
belongs_to :user
attr_accessor :params
def initialize(init_params)
params = init_params
end
end
class StripeProcessor
def self.process_payment(purchase)
user.stripe_customer_id = create_customer(purchase)[‘id’]
execute_charge(purchase)
end
def execute_charge(purchase)
charge = Stripe::Charge.create(
amount: purchase.price_in_cents,
currency: 'usd',
customer: purchase.stripe_customer_id,
description: purchase.description
)
purchase.stripe_charge_id = charge["id"]
purchase.amount = charge["amount"]
purchase.stripe_created_at = Time.at(charge["created"].to_i)
end
def create_customer
response = Stripe::Customer.create(
email: params.email,
card: params.token,
description: params.id
)
end
def set_customer_information(user, customer_id)
c = Stripe::Customer.retrieve(customer_id)
user.card_fingerprint = c["active_card"]["fingerprint"]
user.card_type = c["active_card"]["type"]
user.card_last4 = c["active_card"]["last4"]
user.card_exp_month = c["active_card"]["exp_month"].to_i
user.card_exp_year = c["active_card"]["exp_year"].to_i
end
end
That slimmed down purchase. Now we have another problem. Somewhere between create_customer and execute charge something blows up. If you have a small system this may not be a big deal, but what happens if your site needs to handle a lot of traffic? You could add in a bunch of exceptions and a bunch of logic to catch everything.
The problem is that your code is going become difficult to follow, and tracking the state of charges is going to be a bit of a challenge as the number of charges gets larger.
When we process a purchase we create a customer and execute a charge. During a successful charge they system is actually going through a series of states. pending, creating_customer, executing_charge, processed. If there is an error we need to have a state to tell us that an error happened….so now we have pending, creating_customer, executing_charge, processed, and error. In a payment processing system we want to persist the states as we work through the steps so that we can go back to a charge and see what happened. Was the charge successful? Did if error or or get stuck in a state?
A state machine can help us to clean up the flow and model the system based on these states. Ruby has a few gems to make implementing a state machine easier. Let’s see how our code looks with the state_machine gem.
class Purchase < ActiveRecord::Base
belongs_to :user
attr_accessor :params
def initialize(init_params)
params = init_params
end
state_machine :state do
after_transition :pending => :create_customer, :do => :perform_create_customer
after_transition :creating_customer => :executing_charge, :do => :perform_execute_charge
event :create_customer do
transition :pending => :creating_customer
end
event :execute_charge do
transition :creating_customer => :executing_charge
end
event :finish_charge do
transition :executing_charge => :processed
end
event :create_customer_error do
transition :creating_customer => :error
end
event :execute_charge_error do
transition :executing_charge => :error
end
end
def perform_create_customer
customer = StripeProcessor.create_customer self
if customer[:status] == :success
execute_charge
else
create_customer_error
end
end
def perform_execute_charge
charge = StripeProcessor.execute_charge self
if charge[:status] == :success
finish_charge
else
execute_charge_error
end
end
end
Now we have a very traceable flow of events through the system, but we just added a lot of logic back into the model giving it more than one concern. What you just ran into is one of the biggest problems with the current state_machine gems in Ruby. If you want to persist the state of a state machine, and use these gems, there is no easy way to inject the model into the state machine to cleanly separate the two. This leaves you with two choices. One, roll your own with a skinny model, or two use the existing one. In our case we decide to keep the current gem instead of creating our own state machine framework, or using the traditional Gang of Four class based state machine.
This doesn’t mean that we can’t clean up our model. In our case we pulled our state machine into a module and mixed it in to the model.
module AccountStateMachine
def self.included(base)
base.class_eval do
state_machine :state do
…
end
end
end
end
class Purchase < ActiveRecord::Base
include AccountStateMachine
…
end
You may notice a funky syntax in the AccountStateMachine module using class_eval. That is because the state machine gem is turning the dsl into methods when the class/module is being loaded and binds to the object it was defined in under the DSL. Because of this, when you require the module with the dsl, the state machine dsl will make modifications against the object context it is in, but not include these methods in the object we are trying to include into. The base.class_eval in the included method gets us around that limitation.
It’s not as loosely coupled as injecting the model into the state machine, but for us it seemed like a reasonable compromise.
Special thanks to the development team at PlayonSports for helping me to flesh out this idea.
Leave a Reply