Due to the way strtotime makes calculations, monthly recurrance will not actually be monthly and can skip months if the recurrance starts on the 31st of a month, or the 29th,30th, or 31st if the month happens to be january. This is due to the fact that strtotime will add however many days are in the current month of the given time for "+1 month". So if a customer purchases a monthly subscription on jan 30th, strtotime will add 31 days to that time, making the next recurrance March 1st, which will then become the 1st of the month from then on out, but will skip that initial feb charge. While this will *roughly* equate to monthly, for organizations that book revenue by the month, this can be a particularly sensitive issue, especially if, say converting from one system to another, and doing a massive import of recurring subscribers on the 31st of a month (like we did).

See this note on php.net: http://php.net/manual/en/function.strtotime.php#98878

1128 /**
1129  * Set the intervals after a successful charge.
1130  * @param $fee
1131  *   The fee object passed by reference.
1132  */
1133 function uc_recurring_set_intervals(&$fee) {
1134   $fee->next_charge = strtotime('+'. $fee->regular_interval, $fee->next_charge) - $fee->data['extension'];
1135   if ($fee->remaining_intervals > 0) {
1136     $fee->remaining_intervals--;
1137   }
1138   else {
1139     $order = uc_order_load($fee->order_id);
1140   }
1141   $fee->charged_intervals++;
1142   $fee->attempts = 0;
1143   $fee->data['extension'] = 0;
1144 }

Comments

univate’s picture

Interesting... not sure the best solution here?

tinker’s picture

Possible solution:

// uc_recurring.module

// Correctly adds months 
function uc_recurring_add_month($time, $interval) { 
  $offset = explode(" ", $interval);
  if (count($offset)==2) {
    if (is_numeric($offset[0]) && $offset[1]=='month') {
      $add = $offset[0];
      $month = date('m', $time); 
      $year  = date('Y', $time); 
      $day = date('d', $time);
      if (($month + $add) > 12) {
        $month = ($month + $add) - 12;
        $year++;
      } else {
        $month = $month + $add;
      }
      $daymax = date('t', mktime(0,0,0,$month+1,-1,$year));
      if ($daymax < $day) {
        $day = $daymax;
      }
      $result = mktime(date('H', $time), date('i', $time), date('s', $time), $month, $day , $year);
      return $result;
    }
  }
} 

function uc_recurring_set_intervals(&$fee) {
  if (strstr($fee->regular_interval,'month')) {
    // fix for bad strtotime addition of one month: 01-31-2011 +1 month now returns 02-28-2011
    $fee->next_charge = uc_recurring_add_month($fee->next_charge);
  } else {
    $fee->next_charge = strtotime('+'. $fee->regular_interval, $fee->next_charge) - $fee->data['extension'];
  }
  if ($fee->remaining_intervals > 0) {
    $fee->remaining_intervals--;
  }
  else {
    $order = uc_order_load($fee->order_id);
  }
  $fee->charged_intervals++;
  $fee->attempts = 0;
  $fee->data['extension'] = 0;
}

If you want to test:

$interval = '1 month';
$time = mktime(14,53,30,1,31,2011);
print 'strtotime: ' strftime("%X %x ", strtotime($interval,$time));
print ' vs. ';
print 'add_month:' strftime("%X %x ", uc_recurring_add_month($time, $interval));

/* RETURNS
strtotime: 14:53:30 03/03/11
add_month: 14:53:30 02/28/11 
*/
univate’s picture

Ok, the problem I see here is that then the following month we will start charging on the 28th, instead of the 31st as expected.

EvanDonovan’s picture

Title: Monthly Recurrence can lead to unexpected recurrance date » Setting Monthly Recurrence can lead to unexpected recurrence date (+30 days may cause some months to be skipped)

What if instead of adding an interval, you were to periodize the recurrence - i.e., if someone purchases on the 1st, then make sure that it will always be re-run on the 1st?

I have some code that might help with that. This was written prior to my use of uc_recurring.module, to help calculate when an order would next be billed by Authorize.net's ARB function. It calculates when the next billing date for an order should be based on the Unix timestamp of the initial order.

The only problem that I think there is with this code is that it doesn't handle leap years, so if someone purchases on Feb. 29th, they will never get billed on Feb. 29th, even if that is possible. Also, since this is a theming function, the current return value is an array of a string and a timestamp.

// Theming helper function: calculates the next billing date from a Unix timestamp
function get_next_billing_date($timestamp) {
  if(empty($timestamp)) { return NULL; }
  $purchase_ts = $timestamp;
  // gets the current date (as Unix timestamp)
  $current_ts = time();
  // converts timestamps to date arrays for comparison
  $purchase_date = getdate($purchase_ts);
  $current_date = getdate($current_ts);
  
  // sets the names of the months of the year
  $months = array(1 => 'January', 
                  2 => 'February', 
                  3 => 'March', 
                  4 => 'April', 
                  5 => 'May', 
                  6 => 'June', 
                  7 => 'July', 
                  8 => 'August', 
                  9 => 'September',
                  10 => 'October',
                  11 => 'November', 
                  12 => 'December');
  
  // sets which months are 30 days long
  // TODO: accounting for February that is more sophisticated than what is below
  $short_months = array('April', 'June', 'September', 'November');

  // initializes the $next_bill_date array:
  // the only one to always remain the same is the date of the month
  $next_bill_date = array('month' => '', 'day' => $purchase_date['mday'], 'year' => '');
  
  // special case: just purchased - set it to the next month
  if($purchase_date['mon'] == $current_date['mon'] && 
     $purchase_date['year'] == $current_date['year']) {
     $next_month = $current_date['mon'] + 1;
     $next_bill_date['month'] = $months[$next_month];
     $next_bill_date['year'] = $current_date['year'];
     // for now, just always bill them no later than the 28th
     if($next_bill_date['day'] > 28) { $next_bill_date['day'] = 28; }
  }
     
  // if the purchase date is still to come in the current month                
  if($purchase_date['mday'] > $current_date['mday']) {
    // the next bill date month is the current month
    $next_bill_date['month'] = $months[$current_date['mon']];
    // the next bill date year is the current year
    $next_bill_date['year'] = $current_date['year'];
    // for now, if it is February, never let it fall on the 29th
    if($current_date['mon'] == 2 && $purchase_date['mday'] > 28) {
      // the next bill date month is the third month
      $next_bill_date['month'] = $months[3];
      // the next bill date day is the difference between the purchase day and 28
      $next_bill_date['day'] = $purchase_date['mday'] - 28;
    }
    // handles months with 30 days
    else if(in_array($current_date['mon'], $short_months) && $purchase_date['mday'] == 31) {
      // the next bill date month is the next month
      $next_month = $current_date['mon'] + 1;
      $next_bill_date['month'] = $months[$next_month];
      // the next bill date day is the first of the month
      $next_bill_date['day'] = 1;
    }
  }
  // otherwise, the purchase date has already passed in this month
  else {
    // if it is the last month of the year
    if($current_date['mon'] == 12) {
      // the next bill date month is the first month
      $next_bill_date['month'] = $months[1];
      // the next bill day year is the next year
      $next_bill_date['year'] = $current_date['year'] + 1;
    }
    else {
      // the next bill date month is the next month
      $next_month = $current_date['mon'] + 1;
      $next_bill_date['month'] = $months[$next_month];
      // the next bill day year is the current year
      $next_bill_date['year'] = $current_date['year'];
    }
    // if it is February and they purchased on a day after the 28th
    if($next_bill_date['month'] == 'February' && $purchase_date['mday'] > 28) {
      // for now, just bill on the 28th
      $next_bill_date['day'] = 28;
    }
    // handles months with 30 days
    else if(in_array($next_bill_date['month'], $short_months) && $purchase_date['mday'] == 31) {
      // for now, just bill on the 30th
      $next_bill_date['day'] = 30;
    }
  }
  $next_bill_formatted = $next_bill_date['month'] . ' ' . $next_bill_date['day'] . ', ' . 
                         $next_bill_date['year'];
  $next_bill_ts = strtotime($next_bill_formatted);
  $next_bill = array('formatted' => $next_bill_formatted, 'timestamp' => $next_bill_ts);
  return $next_bill;
}
tinker’s picture

@univate - I understand your concern. The problem is that the module does not store the first recurrence time it only stores the created time and the next_charge time. If you really want it stay on the same day then there are two options:

1 - if recurring billing is restarted or edited overwrite the created time with the new next_charge time
2 - add "activation" time field or store it in serialized in the "data" field

Here is a revised function that takes into account the created date:

function uc_recurring_add_month($fee) { 
  $result = FALSE;
  $time = $fee->next_charge;
  $interval = $fee->regular_interval;
  $created = $fee->created;
  $offset = explode(" ", $interval);

  if (count($offset)==2) {
    if (is_numeric($offset[0]) && $offset[1]=='month') {
      $add = $offset[0];
      $month = date('m', $time); 
      $year  = date('Y', $time); 
      $day = date('d', $time);
      if (($month + $add) > 12) {
        $month = ($month + $add) - 12;
        $year++;
      } else {
        $month = $month + $add;
      }
  
      // make sure day remains the same each month or is the last day of the month
      $daymax = date('t', mktime(0,0,0,$month+1,-1,$year));
      $dayorg = date('d', $created);
      if ($dayorg > $day) {
        $day = $dayorg;
      }
      if ($daymax < $day) {
        $day = $daymax;
      }
      
      $result = mktime(date('H', $time), date('i', $time), date('s', $time), $month, $day , $year);
      return $result;
    }
  }
} 

// TEST
$f->next_charge =mktime(14,53,30,1,31,2011);
$f->created = mktime(14,53,30,12,31,2010);
$f->regular_interval=  '1 month';

print 'created ' . strftime("%X %x", $f->created) ."\n";
print 'current ' . strftime("%X %x", $f->next_charge ) ."\n";

$first = uc_recurring_add_month($f);
$f->next_charge = $first;
print 'first ' . strftime("%X %x", $first) ."\n";

$second = uc_recurring_add_month($f);
print 'second ' . strftime("%X %x", $second) ."\n";

/* RESULTS
created 14:53:30 12/31/10
current 14:53:30 01/31/11
first 14:53:30 02/28/11
second 14:53:30 03/31/11
*/
univate’s picture

I haven't really tested any of this, but if i was to commit something like this I would want to make sure that it didn't mess with other recurring intervals, as monthly is not the only supported interval.

jennypanighetti’s picture

Subscribing.

I too need an accurate monthly recurrence.

tinker’s picture

@univate Could you tell me how the "created date" is used? Would it be OK to use this as the basis for the day of month the billing should occur? This would mean that when the recurrence is edited, the created date would have to be modified, if the day of month changes. Alternatively I could add a value to the "data" field, is that better? I don't see anything else using this value so it would not need a separate field in the table.

The following code change makes sure that only "month" recurrences use the new date calculation so everything would not be affected:

function uc_recurring_set_intervals(&$fee) {
  if (strstr($fee->regular_interval, 'month')) {
    // fix for bad strtotime addition of one month: 01-31-2011 +1 month now returns 02-28-2011
    $fee->next_charge = uc_recurring_add_month($fee);
  } else {
    $fee->next_charge = strtotime('+'. $fee->regular_interval, $fee->next_charge) - $fee->data['extension'];
  }
//....

I could make a patch if you give me some direction.

phen’s picture

FWIW, my 2 cents...

I think the way it works now (as described in the issue summary) is probably better than the proposed solutions, which would result in the billing date skipping around every month. Once it gets to the first or second of the month it should stay there.

I don't know if there is an industry standard --I know my credit card companies all had different schemes for deciding what monthly billing means ... but (FWIW) the way UC_recurring does it matches with the way Paypal does it.

shaundychko’s picture

Priority: Normal » Minor

Here's a link directly to the right part of the PayPal page:
https://cms.paypal.com/us/cgi-bin/?cmd=_render-content&content_ID=develo...

If PayPal does it this way, then we should probably leave it alone.

tinker’s picture

Both ways are the right way it just depends on how the company is keeping its books. If this gets fixed it would allow admin selection to choose which method you want to use.

Mixologic’s picture

Basically there's two, correct logical solutions to this.

  • One is to do it like paypal has solved it, which, given that months are of unequal lengths eventually unifies it to the 1st.
  • The other would be to have it charge on the last day of the month if "one month later" happens to roll past february.

While PayPal does do it that way, thats not to say that everybody in the payment industry does it that way or expects it to be that way.

Authorize.net, for one says the following:

For subscriptions with a monthly interval, whose payments begin on the 31st of a month, payments for months with less than 31 days will occur on the last day of the month.

So this is one of those things that could go either way. On one hand, having an admin selection choose which method you want to use would allow you to set it how you think it should work, on the other hand, thats one more thing to configure, adding more complexity to the system. Im more inclined to say this is a documentation issue, and that when selecting monthly recurrence it should specify the current behavior