Recurring Billing with TrustCommerce

March 18th, 2008 Quick tipsRails

I recently refactored some very old billing code where I used API calls from my TrustCommerce gem directly in my controllers - wow, that's ugly!

Knowing that skinny controller fat model is the way to go I ramped up for a quick refactor...

Here are a few snippets that came out of the refactor that may be of use to others designing a subscription-based site:

Accounts


class Account < ActiveRecord::Base
  ...
  before_create :build_dependencies

  has_one :billing_detail, :dependent => :destroy
  delegate :pricing_plan, :to => :billing_detail
  ...

  private

    def build_dependencies
      # accounts start off on the free trial plan
      build_billing_detail(:pricing_plan_id => PricingPlan.find_by_cents(0).id)
    end
end

Pricing Plans schema


class CreatePricingPlans < ActiveRecord::Migration
  def self.up
    create_table :pricing_plans do |t|
      t.string    :name,  :null => false
      t.integer   :cents, :null => false
      t.datetime  :deleted_at
      t.timestamps
      # add booleans here that reflect allowances of plan
      # Example: If accounts on plan are able to use SSL
      t.integer :ssl, :null => false
      ...
    end

    # create new plans
    PricingPlan.create!(:name => 'Free', :cents => 0, :ssl => true, ...)
    ...
  end

  def self.down
    drop_table :pricing_plans
  end
end

Pricing Plans model


class PricingPlan < ActiveRecord::Base
  acts_as_paranoid
  has_many :billing_detail
  has_many :accounts, :through => :billing_detail

  def paying_plan?
    cents > 0
  end

  def free?
    cents <= 0
  end
end

Billing Details schema


class AddBillingDetails < ActiveRecord::Migration
  def self.up
    create_table :billing_details do |t|
      t.integer   :account_id, :null => false
      t.integer   :pricing_plan_id, :null => false
      t.string    :billing_id
      t.string    :cardholder_name
      t.string    :card_type
      t.string    :credit_card
      t.datetime  :expiration_date
      t.timestamps
    end
  end

  def self.down
    drop_table :billing_details
  end
end

Billing Details model


class BillingDetail < ActiveRecord::Base
  before_validation :update_trustcommerce_account_if_needed
  before_validation :create_trustcommerce_account_if_needed
  before_save :hash_credit_card

  belongs_to :account
  belongs_to :pricing_plan, :with_deleted => true

  validates_presence_of :account_id, :pricing_plan_id
  validates_associated :account, :pricing_plan

  validates_presence_of :billing_id,      :if => :paying_plan?, :message => 'Billing id required for paid plan'
  validates_presence_of :cardholder_name, :if => :paying_plan?, :message => 'Cardholder name required for paid plan'
  validates_presence_of :credit_card,     :if => :paying_plan?, :message => 'Credit card required for paid plan'

  # always reload pricing plan so before_validation can check the new plan
  def paying_plan?
    pricing_plan(true).paying_plan?
  end

  private

    def update_trustcommerce_account_if_needed
      if paying_plan? && !billing_id.blank?
        params = if credit_card =~ /^\*+/
          # no change to credit card, just update amount
          trustcommerce_parameters.only(:billingid, :amount)
        else
          trustcommerce_parameters
        end
        logger.debug "TrustCommerce::Subscription.update(#{params.inspect})"
        response = TrustCommerce::Subscription.update(params)
        logger.debug "TrustCommerce::Subscription.update response: #{response.inspect}"
        if !response.respond_to?(:[]) || response[:status] != 'accepted'
          errors.add(:billing_id, 'Your billing or credit card information appears to be incomplete or incorrect.')
          false
        end
      end
    end

    def create_trustcommerce_account_if_needed
      if paying_plan? && billing_id.blank?
        params = trustcommerce_parameters.except(:billingid)
        logger.debug "TrustCommerce::Subscription.create(#{params.inspect})"
        response = TrustCommerce::Subscription.create(params)
        logger.debug "TrustCommerce::Subscription.create response: #{response.inspect}"
        if response.respond_to?(:[]) && response[:status] == 'approved'
          self.billing_id = response[:billingid]
        else
          errors.add(:billing_id, 'Your billing or credit card information appears to be incomplete or incorrect.')
          false
        end
      end
    end

    def trustcommerce_parameters
      {
        :billingid  => billing_id,
        :name       => cardholder_name,
        :cc         => credit_card,
        :exp        => expiration_date.strftime('%m%y'),
        :amount     => pricing_plan(true).cents,
        :cycle      => '30d'
      }
    end

    def hash_credit_card
      self.credit_card = "************#{credit_card[-4..-1]}" if !credit_card.blank?
    end
end

Note: There is the use of at least one of the hash extensions I use in these snippets.

--- --- ---

2 Comments

  1. Comment by Glenn Gillen on 03/18/08
    Great timing! I'm just about to add some billing functionality into an app that is currently in development, can't wait to see how this will fit in.
  2. Comment by Todd on 04/07/08
    Thanks Zack! Glad I checked your site to see if you'd posted any updates before I started putting together my payment system.

Commenting is closed for this article.