future package unsuspend date, #14144
[freeside.git] / FS / FS / cust_pkg.pm
index 54119b5..855accc 100644 (file)
@@ -605,6 +605,7 @@ sub check {
     || $self->ut_numbern('susp')
     || $self->ut_numbern('cancel')
     || $self->ut_numbern('adjourn')
+    || $self->ut_numbern('resume')
     || $self->ut_numbern('expire')
     || $self->ut_numbern('dundate')
     || $self->ut_enum('no_auto', [ '', 'Y' ])
@@ -618,6 +619,9 @@ sub check {
   return "A package with both start date (future start) and setup date (already started) will never bill"
     if $self->start_date && $self->setup;
 
+  return "A future unsuspend date can only be set for a package with a suspend date"
+    if $self->resume and !$self->susp and !$self->adjourn;
+
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
   if ( $self->dbdef_table->column('manual_flag') ) {
@@ -691,11 +695,19 @@ Available options are:
 
 =item quiet - can be set true to supress email cancellation notices.
 
-=item time -  can be set to cancel the package based on a specific future or historical date.  Using time ensures that the remaining amount is calculated correctly.  Note however that this is an immediate cancel and just changes the date.  You are PROBABLY looking to expire the account instead of using this.
+=item time -  can be set to cancel the package based on a specific future or 
+historical date.  Using time ensures that the remaining amount is calculated 
+correctly.  Note however that this is an immediate cancel and just changes 
+the date.  You are PROBABLY looking to expire the account instead of using 
+this.
 
-=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
+=item reason - can be set to a cancellation reason (see L<FS:reason>), 
+either a reasonnum of an existing reason, or passing a hashref will create 
+a new reason.  The hashref should have the following keys: typenum - Reason 
+type (see L<FS::reason_type>, reason - Text of the new reason.
 
-=item date - can be set to a unix style timestamp to specify when to cancel (expire)
+=item date - can be set to a unix style timestamp to specify when to 
+cancel (expire)
 
 =item nobill - can be set true to skip billing if it might otherwise be done.
 
@@ -739,24 +751,27 @@ sub cancel {
     return "";  # no error
   }
 
-  my $date = $options{date} if $options{date}; # expire/cancel later
-  $date = '' if ($date && $date <= time);      # complain instead?
+  # XXX possibly set cancel_time to the expire date?
+  my $cancel_time = $options{'time'} || time;
+  my $date = $options{'date'} if $options{'date'}; # expire/cancel later
+  $date = '' if ($date && $date <= $cancel_time);      # complain instead?
 
   #race condition: usage could be ongoing until unprovisioned
   #resolved by performing a change package instead (which unprovisions) and
   #later cancelling
-  if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
+  if ( !$options{nobill} && !$date ) {
+    # && $conf->exists('bill_usage_on_cancel') ) { #calc_cancel checks this
       my $copy = $self->new({$self->hash});
       my $error =
-        $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
+        $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
+                                'cancel'   => 1,
+                                'time'     => $cancel_time );
       warn "Error billing during cancel, custnum ".
         #$self->cust_main->custnum. ": $error"
         ": $error"
         if $error;
   }
 
-  my $cancel_time = $options{'time'} || time;
-
   if ( $options{'reason'} ) {
     $error = $self->insert_reason( 'reason' => $options{'reason'},
                                    'action' => $date ? 'expire' : 'cancel',
@@ -790,10 +805,7 @@ sub cancel {
   }
 
   unless ($date) {
-
-    # Add a credit for remaining service
-    my $last_bill = $self->getfield('last_bill') || 0;
-    my $next_bill = $self->getfield('bill') || 0;
+    # credit remaining time if appropriate
     my $do_credit;
     if ( exists($options{'unused_credit'}) ) {
       $do_credit = $options{'unused_credit'};
@@ -801,25 +813,13 @@ sub cancel {
     else {
       $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
     }
-    if ( $do_credit
-          and $last_bill > 0 # the package has been billed
-          and $next_bill > 0 # the package has a next bill date
-          and $next_bill >= $cancel_time # which is in the future
-    ) {
-      my $remaining_value = $self->calc_remain('time' => $cancel_time);
-      if ( $remaining_value > 0 ) {
-        my $error = $self->cust_main->credit(
-          $remaining_value,
-          'Credit for unused time on '. $self->part_pkg->pkg,
-          'reason_type' => $conf->config('cancel_credit_type'),
-        );
-        if ($error) {
-          $dbh->rollback if $oldAutoCommit;
-          return "Error crediting customer \$$remaining_value for unused time".
-                 " on ". $self->part_pkg->pkg. ": $error";
-        }
-      } #if $remaining_value
-    } #if $do_credit
+    if ( $do_credit ) {
+      my $error = $self->credit_remaining('cancel', $cancel_time);
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
 
   } #unless $date
 
@@ -940,9 +940,21 @@ Available options are:
 
 =over 4
 
-=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
+=item reason - can be set to a cancellation reason (see L<FS:reason>), 
+either a reasonnum of an existing reason, or passing a hashref will create 
+a new reason.  The hashref should have the following keys: 
+- typenum - Reason type (see L<FS::reason_type>
+- reason - Text of the new reason.
+
+=item date - can be set to a unix style timestamp to specify when to 
+suspend (adjourn)
 
-=item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
+=item time - can be set to override the current time, for calculation 
+of final invoices or unused-time credits
+
+=item resume_date - can be set to a time when the package should be 
+unsuspended.  This may be more convenient than calling C<unsuspend()>
+separately.
 
 =back
 
@@ -978,15 +990,32 @@ sub suspend {
     return "";  # no error                     # complain on adjourn?
   }
 
+  my $suspend_time = $options{'time'} || time;
   my $date = $options{date} if $options{date}; # adjourn/suspend later
-  $date = '' if ($date && $date <= time);      # complain instead?
+  $date = '' if ($date && $date <= $suspend_time); # complain instead?
 
   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
     dbh->rollback if $oldAutoCommit;
     return "Package $pkgnum expires before it would be suspended.";
   }
 
-  my $suspend_time = $options{'time'} || time;
+  # some false laziness with sub cancel
+  if ( !$options{nobill} && !$date &&
+       $self->part_pkg->option('bill_suspend_as_cancel',1) ) {
+    # kind of a kludge--'bill_suspend_as_cancel' to avoid having to 
+    # make the entire cust_main->bill path recognize 'suspend' and 
+    # 'cancel' separately.
+    warn "Billing $pkgnum on suspension (at $suspend_time)\n" if $DEBUG;
+    my $copy = $self->new({$self->hash});
+    my $error =
+      $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
+                              'cancel'   => 1,
+                              'time'     => $suspend_time );
+    warn "Error billing during suspend, custnum ".
+      #$self->cust_main->custnum. ": $error"
+      ": $error"
+      if $error;
+  }
 
   if ( $options{'reason'} ) {
     $error = $self->insert_reason( 'reason' => $options{'reason'},
@@ -1006,6 +1035,12 @@ sub suspend {
   } else {
     $hash{'susp'} = $suspend_time;
   }
+
+  my $resume_date = $options{'resume_date'} || 0;
+  if ( $resume_date > ($date || $suspend_time) ) {
+    $hash{'resume'} = $resume_date;
+  }
+
   my $new = new FS::cust_pkg ( \%hash );
   $error = $new->replace( $self, options => { $self->options } );
   if ( $error ) {
@@ -1014,6 +1049,14 @@ sub suspend {
   }
 
   unless ( $date ) {
+    # credit remaining time if appropriate
+    if ( $self->part_pkg->option('unused_credit_suspend', 1) ) {
+      my $error = $self->credit_remaining('suspend', $suspend_time);
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
 
     my @labels = ();
 
@@ -1073,6 +1116,48 @@ sub suspend {
   ''; #no errors
 }
 
+=item credit_remaining MODE TIME
+
+Generate a credit for this package for the time remaining in the current 
+billing period.  MODE is either "suspend" or "cancel" (determines the 
+credit type).  TIME is the time of suspension/cancellation.  Both arguments
+are mandatory.
+
+=cut
+
+sub credit_remaining {
+  # Add a credit for remaining service
+  my ($self, $mode, $time) = @_;
+  die 'credit_remaining requires suspend or cancel' 
+    unless $mode eq 'suspend' or $mode eq 'cancel';
+  die 'no suspend/cancel time' unless $time > 0;
+
+  my $conf = FS::Conf->new;
+  my $reason_type = $conf->config($mode.'_credit_type');
+
+  my $last_bill = $self->getfield('last_bill') || 0;
+  my $next_bill = $self->getfield('bill') || 0;
+  if ( $last_bill > 0         # the package has been billed
+      and $next_bill > 0      # the package has a next bill date
+      and $next_bill >= $time # which is in the future
+  ) {
+    my $remaining_value = $self->calc_remain('time' => $time);
+    if ( $remaining_value > 0 ) {
+      warn "Crediting for $remaining_value on package ".$self->pkgnum."\n"
+        if $DEBUG;
+      my $error = $self->cust_main->credit(
+        $remaining_value,
+        'Credit for unused time on '. $self->part_pkg->pkg,
+        'reason_type' => $reason_type,
+      );
+      return "Error crediting customer \$$remaining_value for unused time".
+        " on ". $self->part_pkg->pkg. ": $error"
+        if $error;
+    } #if $remaining_value
+  } #if $last_bill, etc.
+  '';
+}
+
 =item unsuspend [ OPTION => VALUE ... ]
 
 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
@@ -1083,6 +1168,11 @@ Available options are:
 
 =over 4
 
+=item date
+
+Can be set to a date to unsuspend the package in the future (the 'resume' 
+field).
+
 =item adjust_next_bill
 
 Can be set true to adjust the next bill date forward by
@@ -1117,15 +1207,38 @@ sub unsuspend {
 
   my $pkgnum = $old->pkgnum;
   if ( $old->get('cancel') || $self->get('cancel') ) {
-    dbh->rollback if $oldAutoCommit;
+    $dbh->rollback if $oldAutoCommit;
     return "Can't unsuspend cancelled package $pkgnum";
   }
 
   unless ( $old->get('susp') && $self->get('susp') ) {
-    dbh->rollback if $oldAutoCommit;
+    $dbh->rollback if $oldAutoCommit;
     return "";  # no error                     # complain instead?
   }
 
+  my $date = $opt{'date'};
+  if ( $date and $date > time ) { # return an error if $date <= time?
+
+    if ( $old->get('expire') && $old->get('expire') < $date ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Package $pkgnum expires before it would be unsuspended.";
+    }
+
+    my $new = new FS::cust_pkg { $self->hash };
+    $new->set('resume', $date);
+    $error = $new->replace($self, options => $self->options);
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+    else {
+      $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+      return '';
+    }
+  
+  } #if $date 
+
   foreach my $cust_svc (
     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
   ) {
@@ -1166,7 +1279,8 @@ sub unsuspend {
   }
 
   $hash{'susp'} = '';
-  $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
+  $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time;
+  $hash{'resume'} = '' if !$hash{'adjourn'};
   my $new = new FS::cust_pkg ( \%hash );
   $error = $new->replace( $self, options => { $self->options } );
   if ( $error ) {
@@ -1224,6 +1338,7 @@ sub unadjourn {
 
   my %hash = $self->hash;
   $hash{'adjourn'} = '';
+  $hash{'resume'}  = '';
   my $new = new FS::cust_pkg ( \%hash );
   $error = $new->replace( $self, options => { $self->options } );
   if ( $error ) {
@@ -1333,23 +1448,23 @@ sub change {
   }
 
   my $unused_credit = 0;
-  if ( $opt->{'keep_dates'} ) {
-    foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
-                          start_date contract_end ) ) {
-      $hash{$date} = $self->getfield($date);
-    }
-  }
+  my $keep_dates = $opt->{'keep_dates'};
   # Special case.  If the pkgpart is changing, and the customer is
   # going to be credited for remaining time, don't keep setup, bill, 
   # or last_bill dates, and DO pass the flag to cancel() to credit 
   # the customer.
-  if ( $opt->{'pkgpart'} 
-      and $opt->{'pkgpart'} != $self->pkgpart
-      and $self->part_pkg->option('unused_credit_change', 1) ) {
-    $unused_credit = 1;
+  if ( $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart ) {
+    $keep_dates = 0;
+    $unused_credit = 1 if $self->part_pkg->option('unused_credit_change', 1);
     $hash{$_} = '' foreach qw(setup bill last_bill);
   }
 
+  if ( $keep_dates ) {
+    foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
+                          resume start_date contract_end ) ) {
+      $hash{$date} = $self->getfield($date);
+    }
+  }
   # allow $opt->{'locationnum'} = '' to specifically set it to null
   # (i.e. customer default location)
   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
@@ -1413,7 +1528,14 @@ sub change {
 
   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
   #remaining time.
-  $error = $self->cancel( quiet=>1, unused_credit => $unused_credit );
+  #Don't allow billing the package (preceding period packages and/or 
+  #outstanding usage) if we are keeping dates (i.e. location changing), 
+  #because the new package will be billed for the same date range.
+  $error = $self->cancel(
+    quiet         => 1, 
+    unused_credit => $unused_credit,
+    nobill        => $keep_dates
+  );
   if ($error) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -3038,7 +3160,6 @@ sub search {
       } elsif ( @c_where ) {
         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
       }
-      warn $where[-1];
 
     }
     
@@ -3270,7 +3391,8 @@ sub search {
                                   $params->{'cust_fields'}
                                 ),
                      ),
-    'extra_sql'   => "$extra_sql $orderby",
+    'extra_sql'   => $extra_sql,
+    'order_by'    => $orderby,
     'addl_from'   => $addl_from,
     'count_query' => $count_query,
   };