8

I've been working on a large Ruby on Rails application for several years. It was inherited in a poor state but most of the production bugs have been ironed out with time. There are some sections that have not been touched such as the payment processing code. The code works for the most part, except that whenever a charge is denied by the payment processor, the user gets a 500 error instead of a helpful message. I'd like to refactor the code to make it easier to maintain. I'll provide a brief run-down of how it works.

I've removed all the error-handling code from the following snippets.

The maze begins in a controller:

  def submit_credit_card
    ...
    @credit_card = CreditCard.new(params[:credit_card].merge(:user => @user))
    @credit_card.save
    ...
    @submission.do_initial_charge(@user)
    ...
  end

Then in the Submission model:

  def do_initial_charge(user)
    ...
    self.initial_charge = self.charges.create(:charge_type => ChargeType.find(1), :user => user)
    self.initial_charge.process!
    self.initial_charge.settled?
  end

In the Charge model:

  aasm column: 'state' do
    ...
    event :process do
      transitions :from => [:created, :failed], :to => :settled, :guard => :transaction_successful?
    end
    ...
  end

  def initialize(*params)
    super(*params)
    ...
    self.amount = self.charge_type.amount
  end

  def transaction_successful?
    user.reload
    credit_card = CreditCard.where(user_id: user_id).last
    cct = self.cc_transactions.build(:user => user, :credit_card => credit_card, :cc_last_four => credit_card.num_last_four, :amount => amount, :charge_id => id)
    cct.process!
    if self.last_cc_transaction.success
      self.update_attribute(:processed, Time.now)
      return true
    else
      self.fail!
      return false
    end
  end

There are a lot of questionable bits above such as reloading the user and finding the last CreditCard rather than passing in the one just saved. Also this code depends on a ChargeType loaded from the database with a hard-coded ID.

In CcTransaction we continue down the trail:

  def do_process
    response = credit_card.process_transaction(self)
    self.authorization = response.authorization
    self.avs_result    = response.avs_result[:message]
    self.cvv_result    = response.cvv_result[:message]
    self.message       = response.message
    self.params        = response.params.inspect
    self.fraud_review  = response.fraud_review?
    self.success       = response.success?
    self.test          = response.test
    self.response      = response.inspect
    self.save!
    self.success
  end

All this appears to do is save a record in the cc_transactions database table. The actual payment processing is performed in the CreditCard model. I won't bore you with the details of that class. The actual work is performed by ActiveMerchant::Billing::AuthorizeNetCimGateway.

So we have at least 5 models involved (Submission, Charge, ChargeType, CcTransaction, and CreditCard). If I were to do this from scratch, I would only use a single Payment model. There are only 2 charge types, so I would hard code those values as class variables. We don't store credit card details, so that model is unnecessary. Transaction info can be stored in the payments table. Failed payments do not need to be saved.

I could go in and do this refactoring fairly easily except for the requirement that nothing should ever go wrong on the production server. Each of the redundant classes has many methods that could be called from anywhere in the code base. There is a suite of integration tests but the coverage is not 100%.

How should I go about refactoring this while ensuring nothing breaks? If I went through the 5 payment classes and greped every method to find out where they're called there's a high probability I will miss something. The client is already used to how the current code runs and introducing any new bugs is unacceptable. Apart from increasing test coverage to 100%, is there any way to refactor this with certainty that nothing will break?

Reed G. Law
  • 259
  • 1
  • 6
  • 6
    You have mentioned only one aspect of this code that is broken: it gives a 500 error when payment fails. This should be fairly easy to fix without redesigning the whole thing, though. Are there other concrete reasons this code needs to be rewritten? Ugly code that works should usually be left at it is, unless there is a strong rationale for changing it. As you rightly worry, redesign takes a lot of effort and may introduce new problems. –  Nov 30 '15 at 06:32
  • It would seem easier to fix the 500 page but the difficulty stems from the poor design. The 500 page is due to an `AASM::InvalidTransition: Event 'process' cannot transition from 'failed'` exception which masks the real error which is an unsuccessful transaction. There is so much indirection it's hard to get the response back to the user and allow a resubmission. I'm sure it's possible but it seems almost as difficult as refactoring. – Reed G. Law Nov 30 '15 at 06:58
  • 2
    "and greped every method to find out where they're called there's a high probability I will miss something" - sounds like part of the problem is the fact you are using a language where no compiler can tell you exactly where a method is called. In such a situation, you probably have to resist the wish to refactor, how much sense it will ever make. – Doc Brown Nov 30 '15 at 21:16

1 Answers1

20

Consider whether this code really needs refactoring. The code may be ugly and aesthetically displeasing to you as a software developer, but if it works, you probably shouldn't redesign it. And based on your question, it sounds like the code largely works.

This classic article from Joel on Software highlights all of the risks of unnecessarily rewriting code. It is a costly, but very tempting, mistake. The whole thing is worth reading before, but this passage about seemingly unnecessary complexity seems particularly appropriate:

Back to that two page function. Yes, I know, it's just a simple function to display a window, but it has grown little hairs and stuff on it and nobody knows why. Well, I'll tell you why: those are bug fixes. One of them fixes that bug that Nancy had when she tried to install the thing on a computer that didn't have Internet Explorer. Another one fixes that bug that occurs in low memory conditions. Another one fixes that bug that occurred when the file is on a floppy disk and the user yanks out the disk in the middle. That LoadLibrary call is ugly but it makes the code work on old versions of Windows 95.

Each of these bugs took weeks of real-world usage before they were found. The programmer might have spent a couple of days reproducing the bug in the lab and fixing it. If it's like a lot of bugs, the fix might be one line of code, or it might even be a couple of characters, but a lot of work and time went into those two characters.

Yes, the code is needlessly complex. But still, some of the steps that seem unnecessary may be there for a reason. And if you go to rewrite it, you will have to learn all of those lessons over again.

Reloading the user, for example, seems pointless: which is exactly why you should worry about changing it. No developer (even a bad one) would have introduced a step like that in their initial design. It was almost certainly put in there to fix some problem. Maybe it is a problem that refactoring to the "correct" design will eliminate...but maybe not.

Also, another small point, I'm not convinced by your contention that it is unnecessary to save failed payments. I can think of two strong reasons for doing so: logging evidence of possible fraud (e.g. someone trying various card numbers), and customer support (a customer claims they keep trying to pay, and it is not working...you would want evidence of any attempted payments to help troubleshoot this). This makes me think that perhaps you haven't fully thought through the requirements of the system, and they aren't as simple as you believe they are.

Fix individual problems without fundamentally changing the design. You mentioned one problem: there is a 500 error when the payment fails. This problem should be easy to fix, probably just by adding one or two lines at the right location. It isn't sufficient rationale for tearing everything apart to make the "correct" design.

There may be other problems, but if the code works 99% of the time right now, it is unlikely that these are going to be fundamental problems requiring a rewrite, either.

If the current design is embedded throughout the code, then even spending a fair amount of effort to fix a problem without changing the design may be warranted.

In some cases it may, indeed, be necessary to do a larger redesign. But this would require a stronger rationale that you have given so far. For example, if you plan to develop the payment model further and introduce significant new features, cleaning up the code would have some benefit. Or perhaps the design contains a fundamental security flaw that needs to be fixed.

There are reasons to refactor a large chunk of code. But if you do that, increase the test coverage to 100%. This is probably something that will save you time overall.

  • 2
    The quote from Joel's article doesn't apply in this case because I know the current use cases and that a lot of the code is indeed unnecessary. As far as increasing test coverage to 100%, I spent many months getting coverage up to about 80%. The remaining uncovered code is either less critical, harder to test, or both. Even with reported 100% test coverage I wouldn't trust automated tests to catch every possible edge case unless I spent an order of magnitude more time writing tests. Another rational for refactoring is that every time this section is visited it takes a long time to understand. – Reed G. Law Nov 30 '15 at 07:09
  • 3
    @ReedG.Law, one further possibility is to simplify the logic internally, but not change the interface that is exposed to the rest of the code--sort of a halfway redesign that is lower risk. This would still carry some risk of introducing new problems though. –  Nov 30 '15 at 07:16
  • that's a possibility, but there is a large surface area to the interface because of tightly coupled code. I might be able to get rid of the state machine in `Charge` at least. – Reed G. Law Nov 30 '15 at 07:31
  • 2
    @ReedG.Law as an aside, I am surprised that this code would be embedded lots of different places in the site. Most ecommerce sites have a single defined "checkout" path, and I would only expect payment code to be called in that one location. If that isn't the case, maybe the calling code is really what needs attention, before this code? If you really do need to redesign, maybe the process is: 1) make sure the whole site directs to a single path for checkout. 2) after you have done that, you can safely redesign the payment code. –  Nov 30 '15 at 07:46
  • 3
    @ReedG.Law - you say _because I know the current use cases_, yet your original post states _Each of the redundant classes has many methods that could be called from anywhere in the code base_ . Those 2 quotes appear to be mutually exclusive. – Kickstart Nov 30 '15 at 12:43
  • @Kickstart there are a lot of layers of cruft in the application and many functions that are no longer used. It's possible that a field from a `CcTransaction` is displayed in a view with lots of other information. That may constitute a use case, but in fact it's likely not being used. Usually when there's a question of whether a particular feature is needed or not, there's only a single stakeholder and I can simply ask her. Half the time it can be removed, the other half the stakeholder is already accustomed to using a workaround that can be accommodated with some slight change. – Reed G. Law Dec 01 '15 at 04:05
  • @dan1111 there is a single checkout path but there's code to display the last four digits of the user's credit card all over the place. This design is due a previous requirement of storing payment profiles with the payment processor. Credit cards are no longer stored now. Regarding your comment about failed transactions, you may be right that it's wise to save those. But currently they are saved in a way that they are irretrievable for regular users. Usually when a question arises about a transaction, checking the log file is preferable because it contains more information. – Reed G. Law Dec 01 '15 at 04:14
  • Yes this answer is absolutely spot on. Ugly but working is always better than pristine but untested. – Mike Chamberlain Dec 04 '15 at 09:01