import torrus 1.0.9
[freeside.git] / FS / FS / cust_pkg.pm
index c27d458..2ed25a0 100644 (file)
@@ -153,6 +153,10 @@ date
 
 date
 
+=item contract_end
+
+date
+
 =item cancel
 
 date
@@ -259,17 +263,12 @@ sub insert {
     $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
   }
 
-  my $expire_months = $self->part_pkg->option('expire_months', 1);
-  if ( $expire_months && !$self->expire ) {
-    my $start = $self->start_date || $self->setup || time;
-
-    #false laziness w/part_pkg::add_freq
-    my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($start) )[0,1,2,3,4,5];
-    $mon += $expire_months;
-    until ( $mon < 12 ) { $mon -= 12; $year++; }
-
-    #$self->expire( timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year) );
-    $self->expire( timelocal_nocheck(0,0,0,$mday,$mon,$year) );
+  foreach my $action ( qw(expire adjourn contract_end) ) {
+    my $months = $self->part_pkg->option("${action}_months",1);
+    if($months and !$self->$action) {
+      my $start = $self->start_date || $self->setup || time;
+      $self->$action( $self->part_pkg->add_freq($start, $months) );
+    }
   }
 
   local $SIG{HUP} = 'IGNORE';
@@ -596,6 +595,12 @@ Available options are:
 
 =item nobill - can be set true to skip billing if it might otherwise be done.
 
+=item unused_credit - can be set to 1 to credit the remaining time, or 0 to 
+not credit it.  This must be set (by change()) when changing the package 
+to a different pkgpart or location, and probably shouldn't be in any other 
+case.  If it's not set, the 'unused_credit_cancel' part_pkg option will 
+be used.
+
 =back
 
 If there is an error, returns the error, otherwise returns false.
@@ -646,7 +651,6 @@ sub cancel {
         if $error;
   }
 
-
   my $cancel_time = $options{'time'} || time;
 
   if ( $options{'reason'} ) {
@@ -662,7 +666,8 @@ sub cancel {
   }
 
   my %svc;
-  unless ( $date ) {
+  if ( $date ) {
+# copied from below
     foreach my $cust_svc (
       #schwartz
       map  { $_->[0] }
@@ -670,7 +675,21 @@ sub cancel {
       map  { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
     ) {
+      my $error = $cust_svc->cancel( ('date' => $date) );
 
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error expiring cust_svc: $error";
+      }
+    }
+  } else { #!date
+    foreach my $cust_svc (
+      #schwartz
+      map  { $_->[0] }
+      sort { $a->[1] <=> $b->[1] }
+      map  { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
+      qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
+    ) {
       my $error = $cust_svc->cancel;
 
       if ( $error ) {
@@ -678,10 +697,28 @@ sub cancel {
         return "Error cancelling cust_svc: $error";
       }
     }
+  } #if $date
 
-    # Add a credit for remaining service
-    my $remaining_value = $self->calc_remain(time=>$cancel_time);
-    if ( $remaining_value > 0 && !$options{'no_credit'} ) {
+  # Add a credit for remaining service
+  my $last_bill = $self->getfield('last_bill') || 0;
+  my $next_bill = $self->getfield('bill') || 0;
+  my $do_credit;
+  if ( exists($options{'unused_credit'}) ) {
+    $do_credit = $options{'unused_credit'};
+  }
+  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 ) {
+      # && !$options{'no_credit'} ) {
+      # Undocumented, unused option.
+      # part_pkg configuration should decide this anyway.
       my $error = $self->cust_main->credit(
         $remaining_value,
         'Credit for unused time on '. $self->part_pkg->pkg,
@@ -692,8 +729,8 @@ sub cancel {
         return "Error crediting customer \$$remaining_value for unused time on".
                $self->part_pkg->pkg. ": $error";
       }
-    }
-  }
+    } #if $remaining_value
+  } #if $do_credit
 
   my %hash = $self->hash;
   $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
@@ -708,7 +745,9 @@ sub cancel {
   return '' if $date; #no errors
 
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
-  if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
+  if ( !$options{'quiet'} && 
+        $conf->exists('emailcancel', $self->cust_main->agentnum) && 
+        @invoicing_list ) {
     my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
     my $error = '';
     if ( $msgnum ) {
@@ -1119,7 +1158,7 @@ Options are:
 
 =over 4
 
-=item locaitonnum
+=item locationnum
 
 New locationnum, to change the location for this package.
 
@@ -1136,9 +1175,15 @@ New pkgpart (see L<FS::part_pkg>).
 
 New refnum (see L<FS::part_referral>).
 
+=item keep_dates
+
+Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
+susp, adjourn, cancel, expire, and contract_end) to the new package.
+
 =back
 
-At least one option must be specified (otherwise, what's the point?)
+At least one of locationnum, cust_location, pkgpart, refnum must be specified 
+(otherwise, what's the point?)
 
 Returns either the new FS::cust_pkg object or a scalar error.
 
@@ -1196,6 +1241,24 @@ sub change {
     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
   }
 
+  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);
+    }
+  }
+  # 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;
+    $hash{$_} = '' foreach qw(setup bill last_bill);
+  }
+
   # Create the new package.
   my $cust_pkg = new FS::cust_pkg {
     custnum      => $self->custnum,
@@ -1245,7 +1308,7 @@ sub change {
                                                  ? ()
                                                  : ( 'null' => 1 )
                                    )
-      if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover');
+      if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
 
     if ($error) {
       $dbh->rollback if $oldAutoCommit;
@@ -1253,8 +1316,9 @@ sub change {
     }
   }
 
-  #Good to go, cancel old package.
-  $error = $self->cancel( quiet=>1 );
+  #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
+  #remaining time.
+  $error = $self->cancel( quiet=>1, unused_credit => $unused_credit );
   if ($error) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -1275,6 +1339,60 @@ sub change {
 
 }
 
+use Data::Dumper;
+use Storable 'thaw';
+use MIME::Base64;
+sub process_bulk_cust_pkg {
+  my $job = shift;
+  my $param = thaw(decode_base64(shift));
+  warn Dumper($param) if $DEBUG;
+
+  my $old_part_pkg = qsearchs('part_pkg', 
+                              { pkgpart => $param->{'old_pkgpart'} });
+  my $new_part_pkg = qsearchs('part_pkg',
+                              { pkgpart => $param->{'new_pkgpart'} });
+  die "Must select a new package type\n" unless $new_part_pkg;
+  #my $keep_dates = $param->{'keep_dates'} || 0;
+  my $keep_dates = 1; # there is no good reason to turn this off
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
+
+  my $i = 0;
+  foreach my $old_cust_pkg ( @cust_pkgs ) {
+    $i++;
+    $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
+    if ( $old_cust_pkg->getfield('cancel') ) {
+      warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
+        $old_cust_pkg->pkgnum."\n"
+        if $DEBUG;
+      next;
+    }
+    warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
+      if $DEBUG;
+    my $error = $old_cust_pkg->change(
+      'pkgpart'     => $param->{'new_pkgpart'},
+      'keep_dates'  => $keep_dates
+    );
+    if ( !ref($error) ) { # change returns the cust_pkg on success
+      $dbh->rollback;
+      die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
+    }
+  }
+  $dbh->commit if $oldAutoCommit;
+  return;
+}
+
 =item last_bill
 
 Returns the last bill date, or if there is no last bill date, the setup date.
@@ -1376,6 +1494,18 @@ sub calc_recur {
   $self->part_pkg->calc_recur($self, @_);
 }
 
+=item base_recur
+
+Calls the I<base_recur> of the FS::part_pkg object associated with this billing
+item.
+
+=cut
+
+sub base_recur {
+  my $self = shift;
+  $self->part_pkg->base_recur($self, @_);
+}
+
 =item calc_remain
 
 Calls the I<calc_remain> of the FS::part_pkg object associated with this
@@ -1560,24 +1690,31 @@ sub overlimit {
   grep { $_->overlimit } $self->cust_svc(@_);
 }
 
-=item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] 
+=item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
 
 Returns historical services for this package created before END TIMESTAMP and
 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
-(see L<FS::h_cust_svc>).
+(see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
+I<pkg_svc.hidden> flag will be omitted.
 
 =cut
 
 sub h_cust_svc {
   my $self = shift;
-
-  $self->_sort_cust_svc(
+  my ($end, $start, $mode) = @_;
+  my @cust_svc = $self->_sort_cust_svc(
     [ qsearch( 'h_cust_svc',
-               { 'pkgnum' => $self->pkgnum, },
-               FS::h_cust_svc->sql_h_search(@_),
-             )
-    ]
+      { 'pkgnum' => $self->pkgnum, },  
+      FS::h_cust_svc->sql_h_search(@_),  
+    ) ]
   );
+  if ( $mode eq 'I' ) {
+    my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
+    return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
+  }
+  else {
+    return @cust_svc;
+  }
 }
 
 sub _sort_cust_svc {
@@ -1641,6 +1778,13 @@ sub available_part_svc {
           my $part_svc = $_->part_svc;
           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
             $_->quantity - $self->num_cust_svc($_->svcpart);
+
+         # more evil encapsulation breakage
+         if($part_svc->{'Hash'}{'num_avail'} > 0) {
+           my @exports = $part_svc->part_export_did;
+           $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
+         }
+
           $part_svc;
         }
       $self->part_pkg->pkg_svc;
@@ -1680,6 +1824,7 @@ sub part_svc {
       max( 0, $pkg_svc->quantity - $num_cust_svc );
     $part_svc->{'Hash'}{'cust_pkg_svc'} =
       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
+    $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
     $part_svc;
   } $self->part_pkg->pkg_svc;
 
@@ -1801,7 +1946,7 @@ Class method that returns the list of possible status strings for packages
 =cut
 
 tie my %statuscolor, 'Tie::IxHash', 
-  'not yet billed'  => '000000',
+  'not yet billed'  => '009999', #teal? cyan?
   'one-time charge' => '000000',
   'active'          => '00CC00',
   'suspended'       => 'FF9900',
@@ -1895,11 +2040,12 @@ sub labels {
   map { [ $_->label ] } $self->cust_svc;
 }
 
-=item h_labels END_TIMESTAMP [ START_TIMESTAMP ] 
+=item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
 
 Like the labels method, but returns historical information on services that
 were active as of END_TIMESTAMP and (optionally) not cancelled before
-START_TIMESTAMP.
+START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
+I<pkg_svc.hidden> flag will be omitted.
 
 Returns a list of lists, calling the label method for all (historical) services
 (see L<FS::h_cust_svc>) of this billing item.
@@ -2531,6 +2677,22 @@ sub cancel_sql {
   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
 }
 
+=item status_sql
+
+Returns an SQL expression to give the package status as a string.
+
+=cut
+
+sub status_sql {
+"CASE
+  WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
+  WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
+  WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
+  WHEN ".onetime_sql()." THEN 'one-time charge'
+  ELSE 'active'
+END"
+}
+
 =item search HASHREF
 
 (Class method)
@@ -2799,7 +2961,7 @@ sub search {
       "NOT (".FS::cust_pkg->onetime_sql . ")";
   }
   else {
-    foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
+    foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
 
       next unless exists($params->{$field});
 
@@ -3224,6 +3386,21 @@ sub bulk_change {
 sub _upgrade_data {  # class method
   my ($class, %opts) = @_;
   $class->_upgrade_otaker(%opts);
+  my @statements = (
+    # RT#10139, bug resulting in contract_end being set when it shouldn't
+  'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
+    # RT#10830, bad calculation of prorate date near end of year
+    # the date range for bill is December 2009, and we move it forward
+    # one year if it's before the previous bill date (which it should 
+    # never be)
+  'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
+  AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
+  WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
+  );
+  foreach my $sql (@statements) {
+    my $sth = dbh->prepare($sql);
+    $sth->execute or die $sth->errstr;
+  }
 }
 
 =back