prorate_round_day options to round up / down only, #42108
authorMark Wells <mark@freeside.biz>
Tue, 17 May 2016 03:58:12 +0000 (20:58 -0700)
committerMark Wells <mark@freeside.biz>
Tue, 17 May 2016 03:58:12 +0000 (20:58 -0700)
FS/FS/part_pkg/flat.pm
FS/FS/part_pkg/prorate.pm
FS/FS/part_pkg/prorate_Mixin.pm
FS/t/suite/05-prorate_sync_same_day.t [new file with mode: 0755]

index 3e63242..84599ea 100644 (file)
@@ -58,8 +58,9 @@ tie my %contract_years, 'Tie::IxHash', (
                         },
     'prorate_round_day' => {
                           'name' => 'When synchronizing, round the prorated '.
                         },
     'prorate_round_day' => {
                           'name' => 'When synchronizing, round the prorated '.
-                                    'period to the nearest full day',
-                          'type' => 'checkbox',
+                                    'period',
+                          'type' => 'select',
+                          'select_options' => \%FS::part_pkg::prorate_Mixin::prorate_round_day_opts,
                         },
     'add_full_period' => { 'disabled' => 1 }, # doesn't make sense with sync?
 
                         },
     'add_full_period' => { 'disabled' => 1 }, # doesn't make sense with sync?
 
index a81bfda..4cdc3f1 100644 (file)
@@ -22,9 +22,9 @@ use Time::Local qw(timelocal);
                           'type' => 'checkbox',
                         },
     'prorate_round_day'=> {
                           'type' => 'checkbox',
                         },
     'prorate_round_day'=> {
-                          'name' => 'Round the prorated period to the nearest '.
-                                    'full day',
-                          'type' => 'checkbox',
+                          'name' => 'Round the prorated period',
+                          'type' => 'select',
+                          'select_options' => \%FS::part_pkg::prorate_Mixin::prorate_round_day_opts,
                         },
     'prorate_defer_bill'=> {
                         'name' => 'Defer the first bill until the billing day',
                         },
     'prorate_defer_bill'=> {
                         'name' => 'Defer the first bill until the billing day',
index e8d42b9..26fdc35 100644 (file)
@@ -6,6 +6,13 @@ use Time::Local qw( timelocal timelocal_nocheck );
 use Date::Format qw( time2str );
 use List::Util qw( min );
 
 use Date::Format qw( time2str );
 use List::Util qw( min );
 
+tie our %prorate_round_day_opts, 'Tie::IxHash',
+  0   => 'no',
+  1   => 'to the nearest day',
+  2   => 'up to a full day',
+  3   => 'down to a full day',
+;
+
 %info = ( 
   'disabled'  => 1,
   # define all fields that are referenced in this code
 %info = ( 
   'disabled'  => 1,
   # define all fields that are referenced in this code
@@ -16,8 +23,9 @@ use List::Util qw( min );
                 'type' => 'checkbox',
     },
     'prorate_round_day' => { 
                 'type' => 'checkbox',
     },
     'prorate_round_day' => { 
-                'name' => 'When prorating, round to the nearest full day',
-                'type' => 'checkbox',
+                'name' => 'When prorating, round the prorated period',
+                'type' => 'select',
+                'select_options' => \%prorate_round_day_opts,
     },
     'prorate_defer_bill' => {
                 'name' => 'When prorating, defer the first bill until the '.
     },
     'prorate_defer_bill' => {
                 'name' => 'When prorating, defer the first bill until the '.
@@ -219,7 +227,8 @@ sub _endpoints {
 
   # only works for freq >= 1 month; probably can't be fixed
   my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5];
 
   # only works for freq >= 1 month; probably can't be fixed
   my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5];
-  if( $self->option('prorate_round_day',1) ) {
+  my $rounding_mode = $self->option('prorate_round_day',1);
+  if ( $rounding_mode == 1 ) {
     # If the time is 12:00-23:59, move to the next day by adding 18 
     # hours to $mnow.  Because of DST this can end up from 05:00 to 18:59
     # but it's always within the next day.
     # If the time is 12:00-23:59, move to the next day by adding 18 
     # hours to $mnow.  Because of DST this can end up from 05:00 to 18:59
     # but it's always within the next day.
@@ -228,6 +237,19 @@ sub _endpoints {
     ($mday,$mon,$year) = (localtime($mnow))[3..5];
     # Then set $mnow to midnight on that day.
     $mnow = timelocal(0,0,0,$mday,$mon,$year);
     ($mday,$mon,$year) = (localtime($mnow))[3..5];
     # Then set $mnow to midnight on that day.
     $mnow = timelocal(0,0,0,$mday,$mon,$year);
+  } elsif ( $rounding_mode == 2 ) {
+    # Move the time back to midnight. This increases the length of the
+    # prorate interval.
+    $mnow = timelocal(0,0,0,$mday,$mon,$year);
+    ($mday,$mon,$year) = (localtime($mnow))[3..5];
+  } elsif ( $rounding_mode == 3 ) {
+    # If the time is after midnight, move it forward to the next midnight.
+    # This decreases the length of the prorate interval.
+    if ( $sec > 0 or $min > 0 or $hour > 0 ) {
+      # move to one second before midnight, then tick forward
+      $mnow = timelocal(59,59,23,$mday,$mon,$year) + 1;
+      ($mday,$mon,$year) = (localtime($mnow))[3..5];
+    }
   }
   my $mend;
   my $mstart;
   }
   my $mend;
   my $mstart;
diff --git a/FS/t/suite/05-prorate_sync_same_day.t b/FS/t/suite/05-prorate_sync_same_day.t
new file mode 100755 (executable)
index 0000000..91a8efa
--- /dev/null
@@ -0,0 +1,97 @@
+#!/usr/bin/perl
+
+=head2 DESCRIPTION
+
+Tests the effect of ordering and activating two sync_bill_date packages on
+the same day. Ref RT#42108.
+
+Correct: If the packages have prorate_round_day = 1 (round nearest), or 3
+(round down) then the second package should be prorated one day short. If
+they have prorate_round_day = 2 (round up), they should be billed
+for the same amount. In both cases they should have the same next bill date.
+
+=cut
+
+use strict;
+use Test::More tests => 9;
+use FS::Test;
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use Test::MockTime qw(set_fixed_time);
+use FS::cust_main;
+use FS::cust_pkg;
+use FS::Conf;
+my $FS= FS::Test->new;
+
+foreach my $prorate_mode (1, 2, 3) {
+  diag("prorate_round_day = $prorate_mode");
+  # Create a package def with the sync_bill_date option.
+  my $error;
+  my $old_part_pkg = $FS->qsearchs('part_pkg', { pkgpart => 5 });
+  my $part_pkg = $old_part_pkg->clone;
+  BAIL_OUT("existing pkgpart 5 is not a flat monthly package")
+    unless $part_pkg->freq eq '1' and $part_pkg->plan eq 'flat';
+  $error = $part_pkg->insert(
+    options => {  $old_part_pkg->options,
+                  'sync_bill_date' => 1,
+                  'prorate_round_day' => $prorate_mode, }
+  );
+
+  BAIL_OUT("can't configure package: $error") if $error;
+
+  my $pkgpart = $part_pkg->pkgpart;
+  # Create a clean customer with no other packages.
+  my $location = FS::cust_location->new({
+      address1  => '123 Example Street',
+      city      => 'Sacramento',
+      state     => 'CA',
+      country   => 'US',
+      zip       => '94901',
+  });
+  my $cust = FS::cust_main->new({
+      agentnum      => 1,
+      refnum        => 1,
+      last          => 'Customer',
+      first         => 'Sync bill date',
+      invoice_email => 'newcustomer@fake.freeside.biz',
+      bill_location => $location,
+      ship_location => $location,
+  });
+  $error = $cust->insert;
+  BAIL_OUT("can't create test customer: $error") if $error;
+
+  my @pkgs;
+  # Create and bill the first package.
+  set_fixed_time(str2time('2016-03-10 08:00'));
+  $pkgs[0] = FS::cust_pkg->new({ pkgpart => $pkgpart });
+  $error = $cust->order_pkg({ 'cust_pkg' => $pkgs[0] });
+  BAIL_OUT("can't order package: $error") if $error;
+  $error = $cust->bill_and_collect;
+  # Check the amount billed.
+  my ($cust_bill_pkg) = $pkgs[0]->cust_bill_pkg;
+  my $recur = $part_pkg->base_recur;
+  ok( $cust_bill_pkg->recur == $recur, "first package recur is $recur" )
+    or diag("first package recur is ".$cust_bill_pkg->recur);
+
+  # Create and bill the second package.
+  set_fixed_time(str2time('2016-03-10 16:00'));
+  $pkgs[1] = FS::cust_pkg->new({ pkgpart => $pkgpart });
+  $error = $cust->order_pkg({ 'cust_pkg' => $pkgs[1] });
+  BAIL_OUT("can't order package: $error") if $error;
+  $error = $cust->bill_and_collect;
+
+  # Check the amount billed.
+  if ( $prorate_mode == 1 or $prorate_mode == 3 ) {
+    # it should be one day short, in March
+    $recur = sprintf('%.2f', $recur * 30/31);
+  }
+  ($cust_bill_pkg) = $pkgs[1]->cust_bill_pkg;
+  ok( $cust_bill_pkg->recur == $recur, "second package recur is $recur" )
+    or diag("second package recur is ".$cust_bill_pkg->recur);
+
+  my @next_bill = map { time2str('%Y-%m-%d', $_->replace_old->get('bill')) } @pkgs;
+
+  ok( $next_bill[0] eq $next_bill[1],
+    "both packages will bill again on $next_bill[0]" )
+    or diag("first package bill date is $next_bill[0], second package is $next_bill[1]");
+}