I have been battling a major refactor to slim down a payments controller and could use a hand. Step one I am trying to fix my factories. Right now all of the factories work great on their own, but when I try to build associations the FactoryGirl.create(:job, :purchased_with_coupon)
it will setup the association correctly on the coupon but not the payment. This means that the price paid is always is always 1. I just noticed this which you can see the other section commented out. Before I start tackling the bloated controller I need to figure this out for my tests. Thoughts?
Factories
FactoryGirl.define do
factory :job do
category
company
title { FFaker::Company.position }
location { "#{FFaker::Address.city}, #{FFaker::AddressUS.state}" }
language_list { [FFaker::Lorem.word] }
short_description { FFaker::Lorem.sentence }
description { FFaker::HTMLIpsum.body }
application_process { "Please email #{FFaker::Internet.email} about the position." }
trait :featured do |job|
job.is_featured true
end
trait :reviewed do |job|
job.reviewed_at { Time.now }
end
trait :purchased do |job|
job.reviewed_at { Time.now }
job.start_at { Time.now }
job.end_at { AppConfig.product['settings']['job_active_for_day_num'].day.from_now }
job.paid_at { Time.now }
payments { |j| [j.association(:payment)] }
end
trait :purchased_with_coupon do |job|
job.reviewed_at { Time.now }
job.start_at { Time.now }
job.end_at { AppConfig.product['settings']['job_active_for_day_num'].day.from_now }
job.paid_at { Time.now }
association :coupon, factory: :coupon
payments { |j| [j.association(:payment)] }
end
trait :expired do |job|
start_at = (200..500).to_a.sample.days.ago
job.reviewed_at { start_at }
job.start_at { start_at }
job.end_at { |j| j.start_at + AppConfig.product['settings']['job_active_for_day_num'].days }
job.paid_at { start_at }
payments { |j| [j.association(:payment)] }
end
end
end
FactoryGirl.define do
factory :payment do
job
# price_paid { rand(100..150) }
price_paid { 1 }
stripe_customer_token { (0...50).map { (65 + rand(26)).chr }.join }
end
end
FactoryGirl.define do
factory :coupon do
code { rand(25**10) }
percent_discount { rand(100**1) }
start_at { 2.days.ago }
end_at { 30.day.from_now }
trait :executed do |c|
association :job, factory: [:job, :purchased]
c.executed_at { Time.now }
end
end
end
Models
class Job < ActiveRecord::Base
acts_as_paranoid
strip_attributes
acts_as_taggable
acts_as_taggable_on :languages
belongs_to :company
before_validation :find_company
belongs_to :category
has_one :coupon
has_many :payments
before_create :create_slug, :set_price
after_create :update_vanity_url
accepts_attachments_for :company
accepts_nested_attributes_for :company
accepts_nested_attributes_for :coupon
accepts_nested_attributes_for :payments
validates :title,
:location,
:short_description,
presence: true,
format: { with: /\A[\w\d .,:-@]+\z/, message: :bad_format }
validates :application_process,
presence: true,
format: { with: %r{\A[\w\d .,:/@&=?-]+\z}, message: :bad_format }
validates :title, length: { minimum: 10, maximum: 45 }
validates :location, length: { minimum: 10, maximum: 95 }
validates :short_description, length: { minimum: 10, maximum: 245 }
validates :application_process, length: { minimum: 10, maximum: 95 }
validates :description,
:category_id,
:language_list,
presence: true
validates :reviewed_at,
:start_at,
:end_at,
:paid_at,
date: { allow_blank: true }
validates :start_at, date: { before: :end_at, message: :start_at_before_end_at }, if: proc { start_at? }
validates :end_at, date: { after: :start_at, message: :end_at_after_start_at }, if: proc { end_at? }
scope :active, -> { where.not(reviewed_at: nil, paid_at: nil).where('end_at >= ?', Date.today) }
def expired?
end_at.present? && end_at < Date.today
end
def reviewed?
reviewed_at.present?
end
def paid_for?
reviewed? && paid_at.present?
end
def active?
reviewed? && paid_at.present? && end_at <= Date.today
end
private
def set_price
self.price = AppConfig.product['settings']['job_base_price']
end
def create_slug
self.slug = title.downcase.parameterize
end
def update_vanity_url
self.vanity_url = '/jobs/' + company.slug + '/' + slug + '/' + id.to_s + '/'
save
end
def find_company
existing_company = Company.where(email: company.email) if company
self.company = existing_company.first if existing_company.count > 0
end
end
class Coupon < ActiveRecord::Base
acts_as_paranoid
strip_attributes
belongs_to :job
validates :start_at, date: { before: :end_at }
validates :executed_at, date: { allow_blank: true }
validates_presence_of :job, if: proc { executed_at? }
validates_presence_of :executed_at, if: :job
validates :code,
presence: true,
length: { minimum: 10, maximum: 19 },
uniqueness: { case_sensitive: false },
numericality: { only_integer: true }
validates :percent_discount,
inclusion: { in: 1..100 },
length: { minimum: 1, maximum: 3 },
numericality: { only_integer: true },
presence: true
scope :active, -> { where('start_at < ? AND end_at > ? AND executed_at IS ?', Date.today, Date.today, nil) }
def active?
start_at < Date.today && end_at > Date.today && executed_at.nil?
end
def executed?
job_id.present?
end
end
class Payment < ActiveRecord::Base
belongs_to :job
belongs_to :coupon
validates_presence_of :job
validate :coupon_must_be_active
before_create :net_price
Numeric.include CoreExtensions::Numeric::Percentage
attr_accessor :coupon_code
def coupon_code=(code)
@coupon = Coupon.find_by_code(code)
end
def net_price
return job.price unless @coupon
job.price = @coupon.percent_discount.percent_of(job.price)
self.coupon = @coupon
end
private
def coupon_must_be_active
if @coupon
errors[:coupon] << I18n.t('flash_messages.coupons.id.inactive') unless @coupon.active?
elsif @coupon_code.present?
errors[:coupon_code] << I18n.t('flash_messages.coupons.id.not_found')
end
end
end
It looks like the problem is that there is logic outside of your models that is updating the price_paid
column on your Payment
, and possibly setting the coupon_id
on it as well.
So I would recommend duplicating any extra logic that might be coming from your controllers, service classes, etc. into an after(:create)
callback on your factory.
trait :purchased_with_coupon do
# ...other attributes...
association :coupon
after(:create) do |job, evaulator|
discount_value = 100 - job.coupon.percent_discount) / 100.0
calculated_price_paid = job.price * discount_value
create(:payment, price_paid: price_paid, job: job, coupon: coupon)
end
end
Now ultimately, that code belongs in some kind of abstraction, such as a service class that can easily be tested (and used in other tests). However, you mentioned you are getting started on a refactor and want passing tests. I think this is a reasonable compromise until you're ready to abstract it. Ultimately, I would do something like this:
class CreatePaymentWithCoupon
attr_reader :job
def initialize(job)
@job = job
end
def call
job.payments.create(coupon: job.coupon, price_paid: discounted_price)
end
private
def discounted_price
discount_value = (100 - job.coupon.percent_discount) / 100.0
job.price * discount_value
end
end
Then, in your specs:
it "calculates discounted price" do
coupon = create(:coupon, percent_discount: 25)
job = create(:job, :purchased_with_coupon, price: 100)
CreatePaymentWithCoupon.new(job).call
expect(job.payments.first.price_paid).to eq(75.0)
end