views:

382

answers:

0

I'm using the rails state_machine plugin (looks better than aasm) - it looks great - however, I've implemented it for a credit card processing system I wrote and it looks a bit strange... the code looks very un-DRY...

I wonder if anyone can cast a quick eye over it and let me know what they think. In particular, to me the bits that doesn't look very DRY are:

  • the use of :do_screen and :do_submit_to_processor - am I missing something?
  • the need to create an event for every state

I'm sure it's because I'm missing something really obvious - if so I would appreciate any help you can give me, or code examples if you have any.

CreditCardTransaction Model

class CreditCardTransaction < BillingTransaction

include StateLogger

  attr_accessor :message
  cattr_accessor :gateway

  belongs_to :parent, :polymorphic => true
  belongs_to :credit_card, :foreign_key => "parent_id"
  belongs_to :person

  validates_presence_of :parent_id

  before_validation :get_person, :generate_external_reference
  before_save :set_description

  state_machine :initial => :pending do
    before_transition any => any, :do => :log_state_change
    after_transition any => :screening , :do => :do_screen
    after_transition any => :submiting_to_processor, :do => :do_submit_to_processor

    event :screen do
      transition any => :screening
    end

    event :pass_screening do 
      transition :screening => :passed_screening
    end

    event :fail_screening do
      transition :screening => :failed_screening
    end

    event :submit_to_processor do
      transition any => :submiting_to_processor
    end

    event :accepted_by_processor do
      transition :submiting_to_processor => :success
    end

    event :declined_by_processor do
      transition :submiting_to_processor => :declined_by_processor
    end


  end

  def process!
    self.screen if can_screen?
    self.submit_to_processor if can_submit_to_processor?
    self
  end

  # Returns true if successful (valid active card, no duplicate, no fraud etc)      
  def do_screen
    success = true
    self.message ||= ""

    # Check card is active
    unless self.credit_card and self.credit_card.can_process?
      success = false
      self.message += "Invalid credit card or not allow to purchase with this card (check card state)\n"
    end

    # Find duplicate transaction
    duplicate_transactions = self.find_duplicates
    unless duplicate_transactions.blank?
      success = false
      duplicate_transactions.each do |t|
        self.message += "Duplicate transactions found: #{t.id}\n"
      end
    end

    # Add preliminary anti-fraud stuff here...

    # Set flags and return results
    if success
      pass_screening
    else
      fail_screening
      log_progress(self.message)
    end
    success
  end

  def do_submit_to_processor(transition, options = {})
    begin
      response = gateway.purchase(amount_in_cents, self.credit_card, options.with(
          :order_id => self.external_reference,
          :billing_address => self.credit_card.billing_address
         ))
      success   = response.success?
      reference = response.authorization
      self.message   = response.message
      receipt   = response.params
      test      = response.test
    rescue ActiveMerchant::ActiveMerchantError => e
      success   = false
      reference = nil
      self.message   = e.message
      receipt   = {}
      test      = gateway.test?
    end
    if success
      self.accepted_by_processor
    else
      self.declined_by_processor
    end

    # Log progress
    ["success", "reference", "self.message", "receipt", "test"].each do |param_name|
      log_progress(param_name + ": " + eval(param_name).inspect)
    end
  end

  def amount_in_cents
    (self.amount * 100).to_i
  end

  def amount_in_dollars
    self.amount
  end

  def find_duplicates
    # The fields below together defines a unique entry, given a certain amount of time
    time_limit = 5.minutes
    method_name = "find_all_by_"
    params = ""
    # screen by last_digits because cannot do search on encrypted field
    [:person_id, :amount, :parent_type].each do |field|
      method_name += "_and_" unless method_name == "find_all_by_"
      method_name += field.to_s
      params += ", " unless params.blank?
      params += self[field] ? "'#{self[field]}'" : "nil"
    end
    sql = "#{self.class}.#{method_name}( #{params}, :conditions => ['created_at > ? and state = ?', time_limit.ago.utc, 'success' ] )" 
    # puts sql
    results = eval sql
    # now compare encrypted field
    duplicates = []
    results.each do |result|
      duplicates << result if result.id != self.id && result.credit_card &&
                              result.credit_card.number == self.credit_card.number && 
                              result.credit_card.person == self.credit_card.person
    end
    duplicates
  end

private

  def get_person
    self.person = self.parent.person if self.parent
  end

  # generate random order_id for gateway
  def generate_external_reference
    self.external_reference = ActiveSupport::SecureRandom.hex
  end

  def set_description
    self.description = "Payment by #{self.credit_card.type.to_s.capitalize} card ending #{self.credit_card.last_digits}"
  end  

end

state_logger.rb

  module ClassMethods

    # Returns all states (had to dig into source for this, prob is a better way...)
    def states
      state_machine.states.map(&:value)
    end

  end

  # Puts ClassMethods into this module
  # from http://blog.jayfields.com/2006/12/ruby-instance-and-class-methods-from.html
  def self.included(base)
    base.extend(ClassMethods)
  end


protected

  def log_progress(content)
    self.update_attribute( :log, [self[:log], content].compact.join("\n\n") )
  end  

  def log_state_change(transition)
    event, from, to = transition.event, transition.from_name, transition.to_name
    self.log = [ self[:log], "#{event}: State changed from #{from} to #{to} at #{Time.now}" ].compact.join("\n\n")
  end