Sun, 05 Jun 2005

Credit Card Expiration Date Maxim

Steve Friedl has long maintained the "No Dashes Or Spaces" Hall of Shame which catalogs web sites that require visitors to enter their credit card number without spaces as dashes. As he points out, it's trivial to strip out the extraneous characters server-side so there's no reason to have this stupid restriction in web applications.

I would like to proffer an additional maxim of credit card entry. Credit card expiration dates should never be entered using the name of the month. While I don't have an extensive list of offenders like Steve, I would estimate that there are thousands of web sites that request your credit card expiration date with two pull-down menus, one for the month, January, February, etc., and one for the year. As far as I know, every credit card in existence has the expiration printed as either MM/YY or, less commonly, MM-YY.

When somebody is making a purchase online with a credit card, they simply need to copy the card number (hopefully, without stripping out the whitespace) and the expiration date into a form. The customer shouldn't need to mentally convert the format of the expiration date on the card to the format required on the web site. Additionally, there's no reason to use pull-downs to enter the expiration date. A small text box is the perfect format for entering a 5 digit string.

I suggest the following regex be used to validate expiration dates:
^((0?[1-9])|(1[0-2]))[/-]?(2[01])?\d\d$
This is probably even more liberal than necessary. It allows a one- or two-digit month and a two- or four-digit year, optionally separated by either a slash or a dash. Here's a PHP function to validate such a date string and normalize it to MM/YYYY:

   define('MPE_CREDIT_CARD_EXPIRATION', '~^((0?[1-9])|(1[0-2]))[/-]?(2[01])?\d\d$~');

   /**
   *
   * Check whether a string is a valid expiration date.  Returns the
   * normalized string if it is valid; else returns false.
   *
   * @return   mixed
   * @access   public
   */
   function validateExpiration($expiration, $asOf = null, $expiredOK = false) {
      if (is_null($asOf)) {
         $yearMonth = date('Ym');
      } elseif (is_numeric($asOf)) {
         // unix timestamp
         $yearMonth = date('Ym', $asOf);
      } else {
         // assume mysql datetime (YYYY-MM-DD HH:MM:SS)
         $yearMonth = substr(str_replace('-', '', $asOf), 0, 6);
      }
      $expiration = preg_replace('/\s/', '', $expiration);
      if (! preg_match(MPE_CREDIT_CARD_EXPIRATION, $expiration)) {
         return false;
      }
      if (strlen($expiration) <= 4) {
         // [M]MYY => MM/YY
         $expiration = sprintf('%02d/%02d', substr($expiration, 0, -2), substr($expiration, -2));
      } elseif (! preg_match('~[/-]~', $expiration)) {
         // [M]MYYYY => MM/YYYY
         $expiration = sprintf('%02d/%02d', substr($expiration, 0, -4), substr($expiration, -4));
      }
      // [M]M-[YY]YY => [M]M/[YY]YY
      $expiration = str_replace('-', '/', $expiration);
      list($month, $year) = explode('/', $expiration);
      strlen($year) < 4 && $year += 2000;
      $month = sprintf('%02d', $month);
      if (! $expiredOK && $year . $month < $yearMonth) {
         return false;
      }
      return sprintf('%02d/%04d', $month, $year);
   }

It will also verify that the expiration date is in the future, as of the current date or an optional date passed as a second parameter. If the third parameter is true, dates in the past will not be rejected.

tech | Permanent Link

The state is that great fiction by which everyone tries to live at the expense of everyone else. - Frederic Bastiat