0

I am battling an error with nested attributes and trying to fix the cop error at the same time. So here is the walk through. A coupon code may be submitted with the form using nested attributes that may affect the price of the job. This only occurs if the coupon code is valid. In this scenario the coupon code has already been assigned so the first if coupon_code && coupon.nil? is triggered. When the form comes back around the flash message works correctly but simple form does not display the value. I could adjust simple form to have the value with an instance variable but I'm starting to smell something a bit off here in my logic. Also, the smell of Assignment Branch Condition is starting to worry me. I can move forward with this, but the user would like to see the code. I would too.

Cop Error:

app/controllers/payments_controller.rb:9:3: C: Assignment Branch Condition size for update is too high. [17.97/15]

Controller:

class PaymentsController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound, with: :route_not_found_error

  Numeric.include CoreExtensions::Numeric::Percentage

  def update
    @job = Job.find(params[:job_id])
    coupon_code = params[:job][:coupon_attributes][:code]
    coupon = validate_coupon(coupon_code)
    if coupon_code && coupon.nil?
      @coupon_code = coupon_code
      flash.now[:error] = t('flash_messages.coupons.id.not_found')
      render 'payments/new', layout: 'nested/job/payment'
    else
      update_job(@job, coupon)
      update_coupon(coupon, @job) if coupon
      redirect_to @job.vanity_url
    end
  end

  def new
    @job = Job.find(params[:job_id])
    return if reroute?(@job)
    render 'payments/new', layout: 'nested/job/payment'
  end

  private

  def update_job(job, coupon)
    job.start_at = DateTime.now
    job.end_at = AppConfig.product['settings']['job_active_for_day_num'].days.from_now
    job.paid_at = DateTime.now
    job.price = price_job(coupon)
    # job.save
  end

  def validate_coupon(coupon_code)
    return nil unless coupon_code.present?
    coupon = Coupon.active.find_by_code(coupon_code)
    return nil unless coupon.present?
    coupon
  end

  def price_job(coupon)
    price = AppConfig.product['settings']['job_base_price']
    return price unless coupon
    price = coupon.percent_discount.percent_of(price)
    price
  end

  def update_coupon(coupon, job)
    coupon.job_id = job.id
    coupon.executed_at = DateTime.now
    coupon.save
  end
end

View:

ruby:
  content_for :body_id_class, 'PaymentNew'
  content_for :js_instance, 'viewPaymentNew'
  content_for :browser_title, 'Payment'
  job_base_price = AppConfig.product['settings']['job_base_price']
  coupon_code = @coupon_code ||= ''

= simple_form_for(@job, url: job_payment_path, html: { id: 'payment-processor-form' }) do |j|
  div[class='row']
    div[class='col-md-12']
      div[class='panel panel-default']
        div[class='panel-heading']
          h3[class='panel-title']
            |Total Cost
        div[class='panel-body']
          h2[class='job-cost' data-initial = "#{job_base_price}"]
            = number_to_currency(job_base_price)
        div[class='panel-heading']
          h3[class='panel-title']
            |Have a coupon?
        div[class='panel-body']
          div[class='row-inline']
            div[class='row-block row-block-one']
              = j.simple_fields_for :coupon_attributes, @job.coupon do |c|
                = c.input_field :code, maxlength: 50, id: 'coupon-code', class: 'form-control', data: { 'initial' => 0 }, value: coupon_code
            div[class='row-block']
              button[type='button' class='btn btn-primary' id='coupon-verify' ]
                |Verify
            p[class='help-hint']
              = t('simple_form.hints.coupon.code')

  div[class='row']
    div[class='col-md-12']
      = j.button :button, type: 'button', class: 'btn-primary text-uppercase', id: 'purchase-job' do
        = job_posting_button_step_label

Updates

  1. Refactoring this code to work with the post below. Factories fixed factorygirl create model association NoMethodError: undefined method
hhh_
  • 310
  • 2
  • 5
  • 17
Chris Hough
  • 3,389
  • 3
  • 41
  • 80
  • Hey @chrishough! Would you mind putting this line of code at the top of your `update` action and copy/paste the output from the console into your question? `puts params.to_yaml` Or perhaps you'll see something in there that doesn't seem quite right. – Chris Peters Mar 18 '16 at 20:02
  • @ChrisPeters I am working with the following one below, does that help? – Chris Hough Mar 19 '16 at 22:37

1 Answers1

1

You have quite a few code smells going on in that fat old controller. Most of them seem to be symtoms that all is not well on the model layer and that you are not modeling the domain very well.

You might want to consider something like this:

class Job < ActiveRecord::Base
  has_many :payments
end

class Payment < ActiveRecord::Base
  belongs_to :job
  belongs_to :coupon
end

class Coupon < ActiveRecord::Base
  validates_uniqueness_of :code
end

This will let our countroller focus on CRUD'ing a single resouce rather than trying to herd a bunch of cats.

So lets look at enforcing the business logic for coupons.

class Payment < ActiveRecord::Base
  belongs_to :job
  belongs_to :coupon

  validate :coupon_must_be_active

  attr_writer :coupon_code

  def coupon_code=(code)
    coupon = Coupon.find_by(code: code)
    @coupon_code = code
  end

  private 
  def coupon_must_be_active
    if coupon
      errors[:coupon] << "must be active." unless coupon.active?
    elsif @coupon_code.present? 
      errors[:coupon_code] << "is not valid."
    end
  end
end

The custom attribute writer loads the coupon from the a code. The validation sets up our business logic rules.

We really should do the same when it comes to the job pricing:

class Job < ActiveRecord::Base
  after_initialize :set_price

  def set_price
    self.price ||= AppConfig.product['settings']['job_base_price']
  end
end

class Payment < ActiveRecord::Base
  after_initialize :set_price
  validates_presence_of :job

  def net_price
     return job.price unless coupon
     job.price * (coupon.percent_discount * 00.1)
  end

  # ...
end

We can then write our controller like so:

class PaymentsController

  before_action :set_job

  # GET /jobs/:job_id/payments/new
  def new
    @payment = @job.payments.new
  end

  # POST /jobs/:job_id/payments
  def create
    @payment = @job.payments.create(payment_params)
  end

  # PATCH /jobs/:job_id/payments/:id
  def update
    @payment = @job.payments.find(params[:id])
  end

  private

    def set_job
      @job = Job.find(params[:job_id])
    end

    def payment_params
      params.require(:payment)
            .permit(:coupon_code)
    end
end

We can then simply setup the form with:

= simple_form_for([@job, @payment]) do |f|
  = f.input :coupon_code
  = f.submit

Note that you don't want to take the price from the user unless you intend to implement the honor system - you should get it from your models by setting up association callbacks.

max
  • 96,212
  • 14
  • 104
  • 165
  • This is not indented as a drop in replacement in any way. Its intended as a starting point for refactoring. – max Mar 18 '16 at 20:46
  • I really like this approach, and I had considered moving a portion of this to the models. Let me tinker with this and see how this interacts. It feels much simpler overall. – Chris Hough Mar 18 '16 at 22:29
  • an issue to note for this refactor, the payment is not a separate model, it is attached to job model. the payment is just a controller that meshes the 2 models together – Chris Hough Mar 18 '16 at 22:34
  • would you recommend breaking the payment data off of the job table into a separate table to address this? also, a job, would have only one payment and one coupon, so isn't that "has_one" ? – Chris Hough Mar 18 '16 at 22:36
  • Hmmm... I am thinking we could approach this like http://stackoverflow.com/questions/14389105/rails-model-without-a-table without another table too – Chris Hough Mar 18 '16 at 22:44
  • Yes, you should use a separate table! The reason that you usually would use a `has_many` relation is in case you are hooking it up to a payment processing system. In that case you have to build for the eventuality that payments will fail. Like if a user uses their Mastercard and it has insufficient coverage you want to be able to let them try their Visa card instead without losing record of the first transaction. – max Mar 18 '16 at 22:53
  • Don't be afraid of adding tables, models or classes. Most problems are caused by having too few components that try to do everthing. Usually you need something like `Order`, `LineItem`, `Product`, `Payment ` to build a good checkout. – max Mar 18 '16 at 22:59
  • when I am trying to set this up rubocop is barking at "app/models/payment.rb:10:5: W: Useless assignment to variable - coupon. coupon = Coupon.find_by(code: code) ^^^^^^" – Chris Hough Mar 19 '16 at 00:28
  • I have updated the model code, I like where you are taking this, thank you for your help, but now I Am trying to figure out why my factories are blowing up, do those references look right? – Chris Hough Mar 19 '16 at 00:50
  • I am getting the error "NoMethodError: undefined method `payment=' for # Did you mean? payments= payments" – Chris Hough Mar 19 '16 at 00:53
  • You can change it to self.coupon= to avoid the warning in rubucop – max Mar 19 '16 at 00:59
  • awesome, I can adjust that, do you have thoughts on the new factory issues @max => we could setup a chat window too if you are free man, I really appreciate this – Chris Hough Mar 19 '16 at 01:00
  • It should be payments plural i belive. I dont remember how to has_many off the top of my head but ot should be easy to find in the FG docs – max Mar 19 '16 at 01:04
  • now I get NoMethodError: undefined method `each' for # – Chris Hough Mar 19 '16 at 01:05
  • I posted the latest, thoughts? – Chris Hough Mar 19 '16 at 01:33
  • in your Payment model, should that after init be "net price" ? – Chris Hough Mar 20 '16 at 22:19
  • I like where this solution is going but I have another issue, the form can not recognize the coupon code, and when I try to create job.payment.new in the console I do not see it there either... thoughts? – Chris Hough Mar 20 '16 at 23:20
  • ok, sorry for the final update, I solved all of this using your guide + model call backs. if you are interested in seeing the final product let me know, I would be happy to set up a gist for you – Chris Hough Mar 21 '16 at 03:49