backport cust_svc_unsorted method from master, RT#26097
[freeside.git] / FS / FS / cust_pkg.pm
index cd62816..e2d3375 100644 (file)
@@ -2,17 +2,18 @@ package FS::cust_pkg;
 
 use strict;
 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin
-             FS::m2m_Common FS::option_Common FS::Record );
+             FS::m2m_Common FS::option_Common );
 use vars qw($disable_agentcheck $DEBUG $me);
 use Carp qw(cluck);
 use Scalar::Util qw( blessed );
 use List::Util qw(max);
 use Tie::IxHash;
-use Time::Local qw( timelocal_nocheck );
+use Time::Local qw( timelocal timelocal_nocheck );
 use MIME::Entity;
-use FS::UID qw( getotaker dbh );
+use FS::UID qw( getotaker dbh driver_name );
 use FS::Misc qw( send_email );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs fields );
+use FS::CurrentUser;
 use FS::cust_svc;
 use FS::part_pkg;
 use FS::cust_main;
@@ -29,6 +30,7 @@ use FS::reason;
 use FS::cust_pkg_discount;
 use FS::discount;
 use FS::UI::Web;
+use Data::Dumper;
 
 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
 # setup }
@@ -124,6 +126,10 @@ Billing item definition (see L<FS::part_pkg>)
 
 Optional link to package location (see L<FS::location>)
 
+=item order_date
+
+date package was ordered (also remains same on changes)
+
 =item start_date
 
 date
@@ -152,6 +158,10 @@ date
 
 date
 
+=item contract_end
+
+date
+
 =item cancel
 
 date
@@ -185,6 +195,8 @@ Previous pkgpart
 
 Previous locationnum
 
+=item waive_setup
+
 =back
 
 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
@@ -230,7 +242,8 @@ The following options are available:
 
 =item change
 
-If set true, supresses any referral credit to a referring customer.
+If set true, supresses actions that should only be taken for new package
+orders.  (Currently this includes: intro periods when delay_setup is on.)
 
 =item options
 
@@ -251,26 +264,43 @@ an optional queue name for ticket additions
 sub insert {
   my( $self, %options ) = @_;
 
-  if ( $self->part_pkg->option('start_1st', 1) && !$self->start_date ) {
+  my $error = $self->check_pkgpart;
+  return $error if $error;
+
+  my $part_pkg = $self->part_pkg;
+
+  # if the package def says to start only on the first of the month:
+  if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) {
     my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
     $mon += 1 unless $mday == 1;
     until ( $mon < 12 ) { $mon -= 12; $year++; }
     $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++; }
+  # set up any automatic expire/adjourn/contract_end timers
+  # based on the start date
+  foreach my $action ( qw(expire adjourn contract_end) ) {
+    my $months = $part_pkg->option("${action}_months",1);
+    if($months and !$self->$action) {
+      my $start = $self->start_date || $self->setup || time;
+      $self->$action( $part_pkg->add_freq($start, $months) );
+    }
+  }
 
-    #$self->expire( timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year) );
-    $self->expire( timelocal_nocheck(0,0,0,$mday,$mon,$year) );
+  # if this package has "free days" and delayed setup fee, tehn 
+  # set start date that many days in the future.
+  # (this should have been set in the UI, but enforce it here)
+  if (    ! $options{'change'}
+       && ( my $free_days = $part_pkg->option('free_days',1) )
+       && $part_pkg->option('delay_setup',1)
+       #&& ! $self->start_date
+     )
+  {
+    $self->start_date( $part_pkg->default_start_date );
   }
 
+  $self->order_date(time);
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -282,7 +312,7 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
+  $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -365,14 +395,70 @@ sub insert {
 
 This method now works but you probably shouldn't use it.
 
-You don't want to delete billing items, because there would then be no record
-the customer ever purchased the item.  Instead, see the cancel method.
+You don't want to delete packages, because there would then be no record
+the customer ever purchased the package.  Instead, see the cancel method and
+hide cancelled packages.
 
 =cut
 
-#sub delete {
-#  return "Can't delete cust_pkg records!";
-#}
+sub delete {
+  my $self = shift;
+
+  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;
+
+  foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
+    my $error = $cust_pkg_discount->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+  #cust_bill_pkg_discount?
+
+  foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
+    my $error = $cust_pkg_detail->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  foreach my $cust_pkg_reason (
+    qsearchs( {
+                'table' => 'cust_pkg_reason',
+                'hashref' => { 'pkgnum' => $self->pkgnum },
+              }
+            )
+  ) {
+    my $error = $cust_pkg_reason->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  #pkg_referral?
+
+  my $error = $self->SUPER::delete(@_);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
 
 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
 
@@ -475,9 +561,12 @@ sub replace {
 
   }
 
-  my $error = $new->SUPER::replace($old,
-                                   $options->{options} ? $options->{options} : ()
-                                  );
+  my $error =  $new->export_pkg_change($old)
+            || $new->SUPER::replace( $old,
+                                     $options->{options}
+                                       ? $options->{options}
+                                       : ()
+                                   );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -487,7 +576,10 @@ sub replace {
   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
   foreach my $old_svc_acct ( @svc_acct ) {
     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
-    my $s_error = $new_svc_acct->replace($old_svc_acct);
+    my $s_error =
+      $new_svc_acct->replace( $old_svc_acct,
+                              'depend_jobnum' => $options->{depend_jobnum},
+                            );
     if ( $s_error ) {
       $dbh->rollback if $oldAutoCommit;
       return $s_error;
@@ -516,6 +608,7 @@ sub check {
     $self->ut_numbern('pkgnum')
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_numbern('pkgpart')
+    || $self->check_pkgpart
     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
     || $self->ut_numbern('start_date')
     || $self->ut_numbern('setup')
@@ -523,11 +616,45 @@ 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' ])
+    || $self->ut_enum('waive_setup', [ '', 'Y' ])
+    || $self->ut_numbern('agent_pkgid')
+    || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
+    || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
   ;
   return $error if $error;
 
+  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') ) {
+    $self->manual_flag('') if $self->manual_flag eq ' ';
+    $self->manual_flag =~ /^([01]?)$/
+      or return "Illegal manual_flag ". $self->manual_flag;
+    $self->manual_flag($1);
+  }
+
+  $self->SUPER::check;
+}
+
+=item check_pkgpart
+
+=cut
+
+sub check_pkgpart {
+  my $self = shift;
+
+  my $error = $self->ut_numbern('pkgpart');
+  return $error if $error;
+
   if ( $self->reg_code ) {
 
     unless ( grep { $self->pkgpart == $_->pkgpart }
@@ -563,16 +690,8 @@ sub check {
 
   }
 
-  $self->otaker(getotaker) unless $self->otaker;
-
-  if ( $self->dbdef_table->column('manual_flag') ) {
-    $self->manual_flag('') if $self->manual_flag eq ' ';
-    $self->manual_flag =~ /^([01]?)$/
-      or return "Illegal manual_flag ". $self->manual_flag;
-    $self->manual_flag($1);
-  }
+  '';
 
-  $self->SUPER::check;
 }
 
 =item cancel [ OPTION => VALUE ... ]
@@ -595,6 +714,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.
@@ -645,7 +770,6 @@ sub cancel {
         if $error;
   }
 
-
   my $cancel_time = $options{'time'} || time;
 
   if ( $options{'reason'} ) {
@@ -660,39 +784,59 @@ sub cancel {
     }
   }
 
-  my %svc;
-  unless ( $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;
+  my %svc_cancel_opt = ();
+  $svc_cancel_opt{'date'} = $date if $date;
+  foreach my $cust_svc (
+    #schwartz
+    map  { $_->[0] }
+    sort { $a->[1] <=> $b->[1] }
+    map  { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; }
+    qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
+  ) {
+    my $part_svc = $cust_svc->part_svc;
+    next if ( defined($part_svc) and $part_svc->preserve );
+    my $error = $cust_svc->cancel( %svc_cancel_opt );
 
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "Error cancelling cust_svc: $error";
-      }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
+             " cust_svc: $error";
     }
+  }
+
+  unless ($date) {
 
     # Add a credit for remaining service
-    my $remaining_value = $self->calc_remain(time=>$cancel_time);
-    if ( $remaining_value > 0 && !$options{'no_credit'} ) {
-      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";
-      }
+    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 ) {
+        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
+
+  } #unless $date
 
   my %hash = $self->hash;
   $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
@@ -707,13 +851,24 @@ 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 ) {
-    my $error = send_email(
-      'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
-      'to'      => \@invoicing_list,
-      'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
-      'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
-    );
+  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 ) {
+      my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
+      $error = $msg_template->send( 'cust_main' => $self->cust_main,
+                                    'object'    => $self );
+    }
+    else {
+      $error = send_email(
+        'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
+        'to'      => \@invoicing_list,
+        'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
+        'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
+      );
+    }
     #should this do something on errors?
   }
 
@@ -739,6 +894,163 @@ sub cancel_if_expired {
   '';
 }
 
+=item uncancel
+
+"Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
+locationnum, (other fields?).  Attempts to re-provision cancelled services
+using history information (errors at this stage are not fatal).
+
+cust_pkg: pass a scalar reference, will be filled in with the new cust_pkg object
+
+svc_fatal: service provisioning errors are fatal
+
+svc_errors: pass an array reference, will be filled in with any provisioning errors
+
+=cut
+
+sub uncancel {
+  my( $self, %options ) = @_;
+
+  #in case you try do do $uncancel-date = $cust_pkg->uncacel 
+  return '' unless $self->get('cancel');
+
+  ##
+  # Transaction-alize
+  ##
+
+  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;
+
+  ##
+  # insert the new package
+  ##
+
+  my $cust_pkg = new FS::cust_pkg {
+    last_bill       => ( $options{'last_bill'} || $self->get('last_bill') ),
+    bill            => ( $options{'bill'}      || $self->get('bill')      ),
+    uncancel        => time,
+    uncancel_pkgnum => $self->pkgnum,
+    map { $_ => $self->get($_) } qw(
+      custnum pkgpart locationnum
+      setup
+      susp adjourn resume expire start_date contract_end dundate
+      change_date change_pkgpart change_locationnum
+      manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
+    ),
+  };
+
+  my $error = $cust_pkg->insert(
+    'change' => 1, #supresses any referral credit to a referring customer
+  );
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  ##
+  # insert services
+  ##
+
+  #find historical services within this timeframe before the package cancel
+  # (incompatible with "time" option to cust_pkg->cancel?)
+  my $fuzz = 2 * 60; #2 minutes?  too much?   (might catch separate unprovision)
+                     #            too little? (unprovisioing export delay?)
+  my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
+  my @h_cust_svc = $self->h_cust_svc( $end, $start );
+
+  my @svc_errors;
+  foreach my $h_cust_svc (@h_cust_svc) {
+    my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
+    #next unless $h_svc_x; #should this happen?
+    (my $table = $h_svc_x->table) =~ s/^h_//;
+    require "FS/$table.pm";
+    my $class = "FS::$table";
+    my $svc_x = $class->new( {
+      'pkgnum'  => $cust_pkg->pkgnum,
+      'svcpart' => $h_cust_svc->svcpart,
+      map { $_ => $h_svc_x->get($_) } fields($table)
+    } );
+
+    # radius_usergroup
+    if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
+      $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
+    }
+
+    my $svc_error = $svc_x->insert;
+    if ( $svc_error ) {
+      if ( $options{svc_fatal} ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $svc_error;
+      } else {
+        # if we've failed to insert the svc_x object, svc_Common->insert 
+        # will have removed the cust_svc already.  if not, then both records
+        # were inserted but we failed for some other reason (export, most 
+        # likely).  in that case, report the error and delete the records.
+        push @svc_errors, $svc_error;
+        my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
+        if ( $cust_svc ) {
+          # except if export_insert failed, export_delete probably won't be
+          # much better
+          local $FS::svc_Common::noexport_hack = 1;
+          my $cleanup_error = $svc_x->delete; # also deletes cust_svc
+          if ( $cleanup_error ) { # and if THAT fails, then run away
+            $dbh->rollback if $oldAutoCommit;
+            return $cleanup_error;
+          }
+        }
+      } # svc_fatal
+    } # svc_error
+  } #foreach $h_cust_svc
+
+  #these are pretty rare, but should handle them
+  # - dsl_device (mac addresses)
+  # - phone_device (mac addresses)
+  # - dsl_note (ikano notes)
+  # - domain_record (i.e. restore DNS information w/domains)
+  # - inventory_item(?) (inventory w/un-cancelling service?)
+  # - nas (svc_broaband nas stuff)
+  #this stuff is unused in the wild afaik
+  # - mailinglistmember
+  # - router.svcnum?
+  # - svc_domain.parent_svcnum?
+  # - acct_snarf (ancient mail fetching config)
+  # - cgp_rule (communigate)
+  # - cust_svc_option (used by our Tron stuff)
+  # - acct_rt_transaction (used by our time worked stuff)
+
+  ##
+  # also move over any services that didn't unprovision at cancellation
+  ## 
+
+  foreach my $cust_svc ( qsearch('cust_svc', { pkgnum => $self->pkgnum } ) ) {
+    $cust_svc->pkgnum( $cust_pkg->pkgnum );
+    my $error = $cust_svc->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  ##
+  # Finish
+  ##
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  ${ $options{cust_pkg} }   = $cust_pkg   if ref($options{cust_pkg});
+  @{ $options{svc_errors} } = @svc_errors if ref($options{svc_errors});
+
+  '';
+}
+
 =item unexpire
 
 Cancels any pending expiration (sets the expire field to null).
@@ -800,9 +1112,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
 
@@ -838,16 +1162,16 @@ 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;
-
   if ( $options{'reason'} ) {
     $error = $self->insert_reason( 'reason' => $options{'reason'},
                                    'action' => $date ? 'adjourn' : 'suspend',
@@ -860,6 +1184,30 @@ sub suspend {
     }
   }
 
+  my %hash = $self->hash;
+  if ( $date ) {
+    $hash{'adjourn'} = $date;
+  } else {
+    $hash{'susp'} = $suspend_time;
+  }
+
+  my $resume_date = $options{'resume_date'} || 0;
+  if ( $resume_date > ($date || $suspend_time) ) {
+    $hash{'resume'} = $resume_date;
+  }
+
+  $options{options} ||= {};
+
+  my $new = new FS::cust_pkg ( \%hash );
+  $error = $new->replace( $self, options => { $self->options,
+                                              %{ $options{options} },
+                                            }
+                        );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
   unless ( $date ) {
 
     my @labels = ();
@@ -915,19 +1263,6 @@ sub suspend {
 
   }
 
-  my %hash = $self->hash;
-  if ( $date ) {
-    $hash{'adjourn'} = $date;
-  } else {
-    $hash{'susp'} = $suspend_time;
-  }
-  my $new = new FS::cust_pkg ( \%hash );
-  $error = $new->replace( $self, options => { $self->options } );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ''; #no errors
@@ -937,12 +1272,18 @@ sub suspend {
 
 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
 package, then unsuspends the package itself (clears the susp field and the
-adjourn field if it is in the past).
+adjourn field if it is in the past).  If the suspend reason includes an 
+unsuspension package, that package will be ordered.
 
 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
@@ -977,15 +1318,40 @@ 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 
+
+  my @labels = ();
+
   foreach my $cust_svc (
     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
   ) {
@@ -1005,22 +1371,34 @@ sub unsuspend {
         $dbh->rollback if $oldAutoCommit;
         return $error;
       }
+      my( $label, $value ) = $cust_svc->label;
+      push @labels, "$label: $value";
     }
 
   }
 
+  my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
+  my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
+
   my %hash = $self->hash;
   my $inactive = time - $hash{'susp'};
 
   my $conf = new FS::Conf;
 
-  $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
-    if ( $opt{'adjust_next_bill'}
-         || $conf->exists('unsuspend-always_adjust_next_bill_date') )
-    && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
+  if ( $inactive > 0 && 
+       ( $hash{'bill'} || $hash{'setup'} ) &&
+       ( $opt{'adjust_next_bill'} ||
+         $conf->exists('unsuspend-always_adjust_next_bill_date') ||
+         $self->part_pkg->option('unsuspend_adjust_bill', 1) )
+     ) {
+
+    $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
+  
+  }
 
   $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 ) {
@@ -1028,6 +1406,61 @@ sub unsuspend {
     return $error;
   }
 
+  my $unsusp_pkg;
+
+  if ( $reason && $reason->unsuspend_pkgpart ) {
+    my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
+      or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
+                  " not found.";
+    my $start_date = $self->cust_main->next_bill_date 
+      if $reason->unsuspend_hold;
+
+    if ( $part_pkg ) {
+      $unsusp_pkg = FS::cust_pkg->new({
+          'custnum'     => $self->custnum,
+          'pkgpart'     => $reason->unsuspend_pkgpart,
+          'start_date'  => $start_date,
+          'locationnum' => $self->locationnum,
+          # discount? probably not...
+      });
+      
+      $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  if ( $conf->config('unsuspend_email_admin') ) {
+    my $error = send_email(
+      'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
+                                 #invoice_from ??? well as good as any
+      'to'      => $conf->config('unsuspend_email_admin'),
+      'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended',       'body'    => [
+        "This is an automatic message from your Freeside installation\n",
+        "informing you that the following customer package has been unsuspended:\n",
+        "\n",
+        'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
+        'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
+        ( map { "Service : $_\n" } @labels ),
+        ($unsusp_pkg ?
+          "An unsuspension fee was charged: ".
+            $unsusp_pkg->part_pkg->pkg_comment."\n"
+          : ''
+        ),
+      ],
+    );
+
+    if ( $error ) {
+      warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
+           "$error\n";
+    }
+
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ''; #no errors
@@ -1078,6 +1511,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 ) {
@@ -1103,7 +1537,7 @@ Options are:
 
 =over 4
 
-=item locaitonnum
+=item locationnum
 
 New locationnum, to change the location for this package.
 
@@ -1120,9 +1554,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.
 
@@ -1180,12 +1620,34 @@ 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 
+                          resume 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);
+  }
+
+  # allow $opt->{'locationnum'} = '' to specifically set it to null
+  # (i.e. customer default location)
+  $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
+
   # Create the new package.
   my $cust_pkg = new FS::cust_pkg {
     custnum      => $self->custnum,
     pkgpart      => ( $opt->{'pkgpart'}     || $self->pkgpart      ),
     refnum       => ( $opt->{'refnum'}      || $self->refnum       ),
-    locationnum  => ( $opt->{'locationnum'} || $self->locationnum  ),
+    locationnum  => ( $opt->{'locationnum'}                        ),
     %hash,
   };
 
@@ -1229,7 +1691,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;
@@ -1237,8 +1699,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;
@@ -1259,6 +1722,59 @@ sub change {
 
 }
 
+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.
@@ -1360,6 +1876,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
@@ -1500,33 +2028,59 @@ sub num_cust_event {
   $sth->fetchrow_arrayref->[0];
 }
 
-=item cust_svc [ SVCPART ]
+=item cust_svc [ SVCPART ] (old, deprecated usage)
+
+=item cust_svc [ OPTION => VALUE ... ] (current usage)
+
+=item cust_svc_unsorted [ OPTION => VALUE ... ] 
 
 Returns the services for this package, as FS::cust_svc objects (see
-L<FS::cust_svc>).  If a svcpart is specified, return only the matching
-services.
+L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
+spcififed, returns only the matching services.
+
+As an optimization, use the cust_svc_unsorted version if you are not displaying
+the results.
 
 =cut
 
 sub cust_svc {
   my $self = shift;
+  cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
+  $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) );
+}
+
+sub cust_svc_unsorted {
+  my $self = shift;
+  @{ $self->cust_svc_unsorted_arrayref(@_) };
+}
+
+sub cust_svc_unsorted_arrayref {
+  my $self = shift;
 
   return () unless $self->num_cust_svc(@_);
 
-  if ( @_ ) {
-    return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
-                                  'svcpart' => shift,          } );
+  my %opt = ();
+  if ( @_ && $_[0] =~ /^\d+/ ) {
+    $opt{svcpart} = shift;
+  } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
+    %opt = %{ $_[0] };
+  } elsif ( @_ ) {
+    %opt = @_;
   }
 
-  cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
+  my %search = (
+    'table'   => 'cust_svc',
+    'hashref' => { 'pkgnum' => $self->pkgnum },
+  );
+  if ( $opt{svcpart} ) {
+    $search{hashref}->{svcpart} = $opt{'svcpart'};
+  }
+  if ( $opt{'svcdb'} ) {
+    $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
+    $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
+  }
 
-  #if ( $self->{'_svcnum'} ) {
-  #  values %{ $self->{'_svcnum'}->cache };
-  #} else {
-    $self->_sort_cust_svc(
-      [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
-    );
-  #}
+  [ qsearch(\%search) ];
 
 }
 
@@ -1544,24 +2098,33 @@ 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;
+  warn "$me _h_cust_svc called on $self\n"
+    if $DEBUG;
 
-  $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 ( defined($mode) && $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 {
@@ -1584,10 +2147,12 @@ sub _sort_cust_svc {
 
 }
 
-=item num_cust_svc [ SVCPART ]
+=item num_cust_svc [ SVCPART ] (old, deprecated usage)
+
+=item num_cust_svc [ OPTION => VALUE ... ] (current usage)
 
-Returns the number of provisioned services for this package.  If a svcpart is
-specified, counts only the matching services.
+Returns the number of services for this package.  Available options are svcpart
+and svcdb.  If either is spcififed, returns only the matching services.
 
 =cut
 
@@ -1602,11 +2167,31 @@ sub num_cust_svc {
   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
     if $DEBUG > 2;
 
-  my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
-  $sql .= ' AND svcpart = ?' if @_;
+  my %opt = ();
+  if ( @_ && $_[0] =~ /^\d+/ ) {
+    $opt{svcpart} = shift;
+  } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
+    %opt = %{ $_[0] };
+  } elsif ( @_ ) {
+    %opt = @_;
+  }
+
+  my $select = 'SELECT COUNT(*) FROM cust_svc ';
+  my $where = ' WHERE pkgnum = ? ';
+  my @param = ($self->pkgnum);
 
-  my $sth = dbh->prepare($sql)     or die  dbh->errstr;
-  $sth->execute($self->pkgnum, @_) or die $sth->errstr;
+  if ( $opt{'svcpart'} ) {
+    $where .= ' AND svcpart = ? ';
+    push @param, $opt{'svcpart'};
+  }
+  if ( $opt{'svcdb'} ) {
+    $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
+    $where .= ' AND svcdb = ? ';
+    push @param, $opt{'svcdb'};
+  }
+
+  my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
+  $sth->execute(@param) or die $sth->errstr;
   $sth->fetchrow_arrayref->[0];
 }
 
@@ -1625,12 +2210,19 @@ 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;
 }
 
-=item part_svc
+=item part_svc [ OPTION => VALUE ... ]
 
 Returns a list of FS::part_svc objects representing provisioned and available
 services included in this package.  Each FS::part_svc object also has the
@@ -1644,15 +2236,20 @@ following extra fields:
 
 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
 
-svcnum
-label -> ($cust_svc->label)[1]
-
 =back
 
+Accepts one option: summarize_size.  If specified and non-zero, will omit the
+extra cust_pkg_svc option for objects where num_cust_svc is this size or
+greater.
+
 =cut
 
+#svcnum
+#label -> ($cust_svc->label)[1]
+
 sub part_svc {
   my $self = shift;
+  my %opt = @_;
 
   #XXX some sort of sort order besides numeric by svcpart...
   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
@@ -1663,7 +2260,10 @@ sub part_svc {
     $part_svc->{'Hash'}{'num_avail'}    =
       max( 0, $pkg_svc->quantity - $num_cust_svc );
     $part_svc->{'Hash'}{'cust_pkg_svc'} =
-      $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
+        $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
+      unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
+          && $num_cust_svc >= $opt{summarize_size};
+    $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
     $part_svc;
   } $self->part_pkg->pkg_svc;
 
@@ -1694,7 +2294,7 @@ sub extra_part_svc {
   my $self = shift;
 
   my $pkgnum  = $self->pkgnum;
-  my $pkgpart = $self->pkgpart;
+  #my $pkgpart = $self->pkgpart;
 
 #  qsearch( {
 #    'table'     => 'part_svc',
@@ -1713,23 +2313,27 @@ sub extra_part_svc {
 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
 #  } );
 
-#seems to benchmark slightly faster...
+#seems to benchmark slightly faster... (or did?)
+
+  my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
+  my $pkgparts = join(',', @pkgparts);
+
   qsearch( {
     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
     #MySQL doesn't grok DISINCT ON
     'select'      => 'DISTINCT part_svc.*',
     'table'       => 'part_svc',
     'addl_from'   =>
-      'LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
-                               AND pkg_svc.pkgpart   = ?
+      "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
+                               AND pkg_svc.pkgpart IN ($pkgparts)
                                AND quantity > 0
                              )
        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
        LEFT JOIN cust_pkg USING ( pkgnum )
-      ',
+      ",
     'hashref'     => {},
     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
-    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
+    'extra_param' => [ [$self->pkgnum=>'int'] ],
   } );
 }
 
@@ -1765,6 +2369,16 @@ sub status {
   return 'active';
 }
 
+=item ucfirst_status
+
+Returns the status with the first character capitalized.
+
+=cut
+
+sub ucfirst_status {
+  ucfirst(shift->status);
+}
+
 =item statuses
 
 Class method that returns the list of possible status strings for packages
@@ -1775,7 +2389,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',
@@ -1869,11 +2483,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.
@@ -1882,6 +2497,8 @@ Returns a list of lists, calling the label method for all (historical) services
 
 sub h_labels {
   my $self = shift;
+  warn "$me _h_labels called on $self\n"
+    if $DEBUG;
   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
 }
 
@@ -1914,31 +2531,53 @@ sub h_labels_short {
 sub _labels_short {
   my( $self, $method ) = ( shift, shift );
 
+  warn "$me _labels_short called on $self with $method method\n"
+    if $DEBUG;
+
   my $conf = new FS::Conf;
   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
 
+  warn "$me _labels_short populating \%labels\n"
+    if $DEBUG;
+
   my %labels;
   #tie %labels, 'Tie::IxHash';
   push @{ $labels{$_->[0]} }, $_->[1]
-    foreach $self->h_labels(@_);
+    foreach $self->$method(@_);
+
+  warn "$me _labels_short populating \@labels\n"
+    if $DEBUG;
+
   my @labels;
   foreach my $label ( keys %labels ) {
     my %seen = ();
     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
     my $num = scalar(@values);
+    warn "$me _labels_short $num items for $label\n"
+      if $DEBUG;
+
     if ( $num > $max_same_services ) {
+      warn "$me _labels_short   more than $max_same_services, so summarizing\n"
+        if $DEBUG;
       push @labels, "$label ($num)";
     } else {
       if ( $conf->exists('cust_bill-consolidate_services') ) {
+        warn "$me _labels_short   consolidating services\n"
+          if $DEBUG;
         # push @labels, "$label: ". join(', ', @values);
         while ( @values ) {
           my $detail = "$label: ";
           $detail .= shift(@values). ', '
-            while @values && length($detail.$values[0]) < 78;
+            while @values
+               && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
           $detail =~ s/, $//;
           push @labels, $detail;
         }
+        warn "$me _labels_short   done consolidating services\n"
+          if $DEBUG;
       } else {
+        warn "$me _labels_short   adding service data\n"
+          if $DEBUG;
         push @labels, map { "$label: $_" } @values;
       }
     }
@@ -1959,6 +2598,18 @@ sub cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
+=item balance
+
+Returns the balance for this specific package, when using
+experimental package balance.
+
+=cut
+
+sub balance {
+  my $self = shift;
+  $self->cust_main->balance_pkgnum( $self->pkgnum );
+}
+
 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
 
 =item cust_location
@@ -2024,7 +2675,7 @@ sub seconds_since_sqlradacct {
     grep {
       my $part_svc = $_->part_svc;
       $part_svc->svcdb eq 'svc_acct'
-        && scalar($part_svc->part_export('sqlradius'));
+        && scalar($part_svc->part_export_usage);
     } $self->cust_svc
   ) {
     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
@@ -2056,7 +2707,7 @@ sub attribute_since_sqlradacct {
     grep {
       my $part_svc = $_->part_svc;
       $part_svc->svcdb eq 'svc_acct'
-        && scalar($part_svc->part_export('sqlradius'));
+        && scalar($part_svc->part_export_usage);
     } $self->cust_svc
   ) {
     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
@@ -2228,6 +2879,39 @@ sub reexport {
 
 }
 
+=item export_pkg_change OLD_CUST_PKG
+
+Calls the "pkg_change" export action for all services attached to this package.
+
+=cut
+
+sub export_pkg_change {
+  my( $self, $old )  = ( shift, shift );
+
+  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;
+
+  foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
+    my $error = $svc_x->export('pkg_change', $self, $old);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =item insert_reason
 
 Associates this package with a (suspension or cancellation) reason (see
@@ -2328,12 +3012,12 @@ sub insert_discount {
     'discountnum' => $self->discountnum,
     'months_used' => 0,
     'end_date'    => '', #XXX
-    'otaker'      => $self->otaker,
     #for the create a new discount case
     '_type'       => $self->discountnum__type,
     'amount'      => $self->discountnum_amount,
     'percent'     => $self->discountnum_percent,
     'months'      => $self->discountnum_months,
+    'setup'      => $self->discountnum_setup,
     #'disabled'    => $self->discountnum_disabled,
   };
 
@@ -2353,7 +3037,8 @@ All svc_accts which are part of this package have their values reset.
 sub set_usage {
   my ($self, $valueref, %opt) = @_;
 
-  foreach my $cust_svc ($self->cust_svc){
+  #only svc_acct can set_usage for now
+  foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
     my $svc_x = $cust_svc->svc_x;
     $svc_x->set_usage($valueref, %opt)
       if $svc_x->can("set_usage");
@@ -2373,7 +3058,8 @@ All svc_accts which are part of this package have their values incremented.
 sub recharge {
   my ($self, $valueref) = @_;
 
-  foreach my $cust_svc ($self->cust_svc){
+  #only svc_acct can set_usage for now
+  foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
     my $svc_x = $cust_svc->svc_x;
     $svc_x->recharge($valueref)
       if $svc_x->can("recharge");
@@ -2426,14 +3112,25 @@ sub onetime_sql { "
             where cust_pkg.pkgpart = part_pkg.pkgpart )
 "; }
 
+=item ordered_sql
+
+Returns an SQL expression identifying ordered packages (recurring packages not
+yet billed).
+
+=cut
+
+sub ordered_sql {
+   $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
+}
+
 =item active_sql
 
 Returns an SQL expression identifying active packages.
 
 =cut
 
-sub active_sql { "
-  ". $_[0]->recurring_sql(). "
+sub active_sql {
+  $_[0]->recurring_sql. "
   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
@@ -2494,6 +3191,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)
@@ -2563,6 +3276,17 @@ a value suited to passing to FS::UI::Web::cust_header
 
 specifies the user for agent virtualization
 
+=item fcc_line
+
+boolean; if true, returns only packages with more than 0 FCC phone lines
+
+=item state, country
+
+Limit to packages whose customer is located in the specified state and 
+country.  For FCC 477 reporting.  This will use the customer's service 
+address if there is one, but isn't yet smart enough to use the package 
+address.
+
 =back
 
 =cut
@@ -2590,6 +3314,15 @@ sub search {
   }
 
   ##
+  # custbatch
+  ##
+
+  if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+    push @where,
+      "cust_pkg.pkgbatch = '$1'";
+  }
+
+  ##
   # parse status
   ##
 
@@ -2624,45 +3357,55 @@ sub search {
   # parse package class
   ###
 
-  #false lazinessish w/graph/cust_bill_pkg.cgi
-  my $classnum = 0;
-  my @pkg_class = ();
-  if ( exists($params->{'classnum'})
-       && $params->{'classnum'} =~ /^(\d*)$/
-     )
-  {
-    $classnum = $1;
-    if ( $classnum ) { #a specific class
-      push @where, "part_pkg.classnum = $classnum";
-
-      #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
-      #die "classnum $classnum not found!" unless $pkg_class[0];
-      #$title .= $pkg_class[0]->classname.' ';
-
-    } elsif ( $classnum eq '' ) { #the empty class
-
-      push @where, "part_pkg.classnum IS NULL";
-      #$title .= 'Empty class ';
-      #@pkg_class = ( '(empty class)' );
-    } elsif ( $classnum eq '0' ) {
-      #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
-      #push @pkg_class, '(empty class)';
-    } else {
-      die "illegal classnum";
+  if ( exists($params->{'classnum'}) ) {
+
+    my @classnum = ();
+    if ( ref($params->{'classnum'}) ) {
+
+      if ( ref($params->{'classnum'}) eq 'HASH' ) {
+        @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
+      } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
+        @classnum = @{ $params->{'classnum'} };
+      } else {
+        die 'unhandled classnum ref '. $params->{'classnum'};
+      }
+
+
+    } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
+      @classnum = ( $1 );
     }
+
+    if ( @classnum ) {
+
+      my @c_where = ();
+      my @nums = grep $_, @classnum;
+      push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
+      my $null = scalar( grep { $_ eq '' } @classnum );
+      push @c_where, 'part_pkg.classnum IS NULL' if $null;
+
+      if ( scalar(@c_where) == 1 ) {
+        push @where, @c_where;
+      } elsif ( @c_where ) {
+        push @where, ' ( '. join(' OR ', @c_where). ' ) ';
+      }
+
+    }
+    
+
   }
-  #eslaf
 
   ###
   # parse package report options
   ###
 
   my @report_option = ();
-  if ( exists($params->{'report_option'})
-       && $params->{'report_option'} =~ /^([,\d]*)$/
-     )
-  {
-    @report_option = split(',', $1);
+  if ( exists($params->{'report_option'}) ) {
+    if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
+      @report_option = @{ $params->{'report_option'} };
+    } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
+      @report_option = split(',', $1);
+    }
+
   }
 
   if (@report_option) {
@@ -2675,7 +3418,27 @@ sub search {
          } @report_option;
   }
 
-  #eslaf
+  foreach my $any ( grep /^report_option_any/, keys %$params ) {
+
+    my @report_option_any = ();
+    if ( ref($params->{$any}) eq 'ARRAY' ) {
+      @report_option_any = @{ $params->{$any} };
+    } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
+      @report_option_any = split(',', $1);
+    }
+
+    if (@report_option_any) {
+      # this will result in the empty set for the dangling comma case as it should
+      push @where, ' ( '. join(' OR ',
+        map{ "0 < ( SELECT count(*) FROM part_pkg_option
+                      WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+                      AND optionname = 'report_option_$_'
+                      AND optionvalue = '1' )"
+           } @report_option_any
+      ). ' ) ';
+    }
+
+  }
 
   ###
   # parse custom
@@ -2684,6 +3447,13 @@ sub search {
   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
 
   ###
+  # parse fcc_line
+  ###
+
+  push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
+                                                        if $params->{fcc_line};
+
+  ###
   # parse censustract
   ###
 
@@ -2695,6 +3465,35 @@ sub search {
   }
 
   ###
+  # parse censustract2
+  ###
+  if ( exists($params->{'censustract2'})
+       && $params->{'censustract2'} =~ /^(\d*)$/
+     )
+  {
+    if ($1) {
+      push @where, "cust_main.censustract LIKE '$1%'";
+    } else {
+      push @where,
+        "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
+    }
+  }
+
+  ###
+  # parse country/state
+  ###
+
+  for (qw(state country)) {
+    if ( exists($params->{$_})
+      && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
+    {
+      push @where, 
+        "COALESCE(cust_location.$_, cust_main.ship_$_, cust_main.$_) = '$1'";
+    }
+  }
+
+
+  ###
   # parse part_pkg
   ###
 
@@ -2743,7 +3542,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 change_date cancel )) {
 
       next unless exists($params->{$field});
 
@@ -2818,29 +3617,73 @@ sub search {
 
   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
-                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
+                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
+                  'LEFT JOIN cust_location USING ( locationnum ) ';
+
+  my $select;
+  my $count_query;
+  if ( $params->{'select_zip5'} ) {
+    my $zip = 'COALESCE(cust_location.zip, cust_main.ship_zip, cust_main.zip)';
 
-  my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
+    $select = "DISTINCT substr($zip,1,5) as zip";
+    $orderby = "ORDER BY substr($zip,1,5)";
+    $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
+  } else {
+    $select = join(', ',
+                         'cust_pkg.*',
+                         ( map "part_pkg.$_", qw( pkg freq ) ),
+                         'pkg_class.classname',
+                         'cust_main.custnum AS cust_main_custnum',
+                         FS::UI::Web::cust_sql_fields(
+                           $params->{'cust_fields'}
+                         ),
+                  );
+    $count_query = 'SELECT COUNT(*)';
+  }
+
+  $count_query .= " FROM cust_pkg $addl_from $extra_sql";
 
   my $sql_query = {
     'table'       => 'cust_pkg',
     'hashref'     => {},
-    'select'      => join(', ',
-                                'cust_pkg.*',
-                                ( map "part_pkg.$_", qw( pkg freq ) ),
-                                'pkg_class.classname',
-                                'cust_main.custnum AS cust_main_custnum',
-                                FS::UI::Web::cust_sql_fields(
-                                  $params->{'cust_fields'}
-                                ),
-                     ),
-    'extra_sql'   => "$extra_sql $orderby",
+    'select'      => $select,
+    'extra_sql'   => $extra_sql,
+    'order_by'    => $orderby,
     'addl_from'   => $addl_from,
     'count_query' => $count_query,
   };
 
 }
 
+=item fcc_477_count
+
+Returns a list of two package counts.  The first is a count of packages
+based on the supplied criteria and the second is the count of residential
+packages with those same criteria.  Criteria are specified as in the search
+method.
+
+=cut
+
+sub fcc_477_count {
+  my ($class, $params) = @_;
+
+  my $sql_query = $class->search( $params );
+
+  my $count_sql = delete($sql_query->{'count_query'});
+  $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
+    or die "couldn't parse count_sql";
+
+  my $count_sth = dbh->prepare($count_sql)
+    or die "Error preparing $count_sql: ". dbh->errstr;
+  $count_sth->execute
+    or die "Error executing $count_sql: ". $count_sth->errstr;
+  my $count_arrayref = $count_sth->fetchrow_arrayref;
+
+  return ( @$count_arrayref );
+
+}
+
+
 =item location_sql
 
 Returns a list: the first item is an SQL fragment identifying matching 
@@ -2858,7 +3701,13 @@ sub location_sql {
 
   # '?' placeholders in _location_sql_where
   my $x = $ornull ? 3 : 2;
-  my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
+  my @bill_param = ( 
+    ('district')x3,
+    ('city')x3, 
+    ('county')x$x,
+    ('state')x$x,
+    'country'
+  );
 
   my $main_where;
   my @main_param;
@@ -2917,19 +3766,31 @@ sub _location_sql_where {
 
   $ornull = $ornull ? ' OR ? IS NULL ' : '';
 
-  my $or_empty_city   = " OR ( ? = '' AND $table.${prefix}city   IS NULL ) ";
-  my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
-  my $or_empty_state =  " OR ( ? = '' AND $table.${prefix}state  IS NULL ) ";
+  my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
+  my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
+  my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
+
+  my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
 
 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
   "
-        ( $table.${prefix}city    = ? OR ? = '' OR CAST(? AS text) IS NULL )
-    AND ( $table.${prefix}county  = ? $or_empty_county $ornull )
-    AND ( $table.${prefix}state   = ? $or_empty_state  $ornull )
-    AND   $table.${prefix}country = ?
+        ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
+    AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
+    AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
+    AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
+    AND   $table.${prefix}country  = ?
   ";
 }
 
+sub _X_show_zero {
+  my( $self, $what ) = @_;
+
+  my $what_show_zero = $what. '_show_zero';
+  length($self->$what_show_zero())
+    ? ($self->$what_show_zero() eq 'Y')
+    : $self->part_pkg->$what_show_zero();
+}
+
 =head1 SUBROUTINES
 
 =over 4
@@ -3139,6 +4000,25 @@ 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\'',
+    # RT6628, add order_date to cust_pkg
+    'update cust_pkg set order_date = (select history_date from h_cust_pkg 
+       where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
+       history_action = \'insert\') where order_date is null',
+  );
+  foreach my $sql (@statements) {
+    my $sth = dbh->prepare($sql);
+    $sth->execute or die $sth->errstr;
+  }
 }
 
 =back