import torrus 1.0.9
[freeside.git] / FS / FS / cust_pkg.pm
index c671163..2ed25a0 100644 (file)
@@ -1,15 +1,19 @@
 package FS::cust_pkg;
 
 use strict;
-use vars qw(@ISA $disable_agentcheck $DEBUG);
+use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin
+             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 MIME::Entity;
 use FS::UID qw( getotaker dbh );
 use FS::Misc qw( send_email );
 use FS::Record qw( qsearch qsearchs );
-use FS::m2m_Common;
-use FS::cust_main_Mixin;
+use FS::CurrentUser;
 use FS::cust_svc;
 use FS::part_pkg;
 use FS::cust_main;
@@ -23,6 +27,8 @@ use FS::reg_code;
 use FS::part_svc;
 use FS::cust_pkg_reason;
 use FS::reason;
+use FS::cust_pkg_discount;
+use FS::discount;
 use FS::UI::Web;
 
 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
@@ -36,9 +42,8 @@ use FS::svc_forward;
 # for sending cancel emails in sub cancel
 use FS::Conf;
 
-@ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
-
 $DEBUG = 0;
+$me = '[FS::cust_pkg]';
 
 $disable_agentcheck = 0;
 
@@ -120,6 +125,10 @@ Billing item definition (see L<FS::part_pkg>)
 
 Optional link to package location (see L<FS::location>)
 
+=item start_date
+
+date
+
 =item setup
 
 date
@@ -144,13 +153,17 @@ date
 
 date
 
+=item contract_end
+
+date
+
 =item cancel
 
 date
 
-=item otaker
+=item usernum
 
-order taker (assigned automatically if null, see L<FS::UID>)
+order taker (see L<FS::access_user>)
 
 =item manual_flag
 
@@ -228,6 +241,14 @@ If set true, supresses any referral credit to a referring customer.
 
 cust_pkg_option records will be created
 
+=item ticket_subject
+
+a ticket will be added to this customer with this subject
+
+=item ticket_queue
+
+an optional queue name for ticket additions
+
 =back
 
 =cut
@@ -235,6 +256,21 @@ cust_pkg_option records will be created
 sub insert {
   my( $self, %options ) = @_;
 
+  if ( $self->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) );
+  }
+
+  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';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -259,6 +295,14 @@ sub insert {
                       'params'       => $self->refnum,
                     );
 
+  if ( $self->discountnum ) {
+    my $error = $self->insert_discount();
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   #if ( $self->reg_code ) {
   #  my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
   #  $error = $reg_code->delete;
@@ -270,6 +314,33 @@ sub insert {
 
   my $conf = new FS::Conf;
 
+  if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
+
+    #eval '
+    #  use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
+    #  use RT;
+    #';
+    #die $@ if $@;
+    #
+    #RT::LoadConfig();
+    #RT::Init();
+    use FS::TicketSystem;
+    FS::TicketSystem->init();
+
+    my $q = new RT::Queue($RT::SystemUser);
+    $q->Load($options{ticket_queue}) if $options{ticket_queue};
+    my $t = new RT::Ticket($RT::SystemUser);
+    my $mime = new MIME::Entity;
+    $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
+    $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
+                Subject => $options{ticket_subject},
+                MIMEObj => $mime,
+              );
+    $t->AddLink( Type   => 'MemberOf',
+                 Target => 'freeside://freeside/cust_main/'. $self->custnum,
+               );
+  }
+
   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
     my $queue = new FS::queue {
       'job'     => 'FS::cust_main::queueable_print',
@@ -353,7 +424,7 @@ sub replace {
       : { @_ };
 
   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
-  return "Can't change otaker!" if $old->otaker ne $new->otaker;
+  #return "Can't change otaker!" if $old->otaker ne $new->otaker;
 
   #allow this *sigh*
   #return "Can't change setup once it exists!"
@@ -439,21 +510,21 @@ replace methods.
 sub check {
   my $self = shift;
 
-  $self->locationnum('')
-    if defined($self->locationnum) && length($self->locationnum)
-    && ( $self->locationnum == 0 || $self->locationnum == -1 );
+  $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
 
   my $error = 
     $self->ut_numbern('pkgnum')
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_numbern('pkgpart')
     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
+    || $self->ut_numbern('start_date')
     || $self->ut_numbern('setup')
     || $self->ut_numbern('bill')
     || $self->ut_numbern('susp')
     || $self->ut_numbern('cancel')
     || $self->ut_numbern('adjourn')
     || $self->ut_numbern('expire')
+    || $self->ut_enum('no_auto', [ '', 'Y' ])
   ;
   return $error if $error;
 
@@ -481,10 +552,10 @@ sub check {
     unless ( $disable_agentcheck ) {
       my $agent =
         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
-      my $pkgpart_href = $agent->pkgpart_hashref;
-      return "agent ". $agent->agentnum.
+      return "agent ". $agent->agentnum. ':'. $agent->agent.
              " can't purchase pkgpart ". $self->pkgpart
-        unless $pkgpart_href->{ $self->pkgpart };
+        unless $agent->pkgpart_hashref->{ $self->pkgpart }
+            || $agent->agentnum == $self->part_pkg->agentnum;
     }
 
     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
@@ -492,9 +563,7 @@ sub check {
 
   }
 
-  $self->otaker(getotaker) unless $self->otaker;
-  $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
-  $self->otaker($1);
+  $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
   if ( $self->dbdef_table->column('manual_flag') ) {
     $self->manual_flag('') if $self->manual_flag eq ' ';
@@ -524,6 +593,14 @@ Available options are:
 
 =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.
+
+=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.
@@ -534,6 +611,8 @@ sub cancel {
   my( $self, %options ) = @_;
   my $error;
 
+  my $conf = new FS::Conf;
+
   warn "cust_pkg::cancel called with options".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
     if $DEBUG;
@@ -559,6 +638,19 @@ sub cancel {
   my $date = $options{date} if $options{date}; # expire/cancel later
   $date = '' if ($date && $date <= 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') ) {
+      my $copy = $self->new({$self->hash});
+      my $error =
+        $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
+      warn "Error billing during cancel, custnum ".
+        #$self->cust_main->custnum. ": $error"
+        ": $error"
+        if $error;
+  }
+
   my $cancel_time = $options{'time'} || time;
 
   if ( $options{'reason'} ) {
@@ -574,7 +666,8 @@ sub cancel {
   }
 
   my %svc;
-  unless ( $date ) {
+  if ( $date ) {
+# copied from below
     foreach my $cust_svc (
       #schwartz
       map  { $_->[0] }
@@ -582,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 ) {
@@ -590,11 +697,28 @@ sub cancel {
         return "Error cancelling cust_svc: $error";
       }
     }
-
-    # Add a credit for remaining service
-    my $remaining_value = $self->calc_remain(time=>$cancel_time);
-    if ( $remaining_value > 0 && !$options{'no_credit'} ) {
-      my $conf = new FS::Conf;
+  } #if $date
+
+  # 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,
@@ -605,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);
@@ -620,16 +744,25 @@ sub cancel {
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   return '' if $date; #no errors
 
-  my $conf = new FS::Conf;
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
-  if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
-    my $conf = new FS::Conf;
-    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?
   }
 
@@ -930,10 +1063,16 @@ sub unsuspend {
 
   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;
@@ -1019,7 +1158,7 @@ Options are:
 
 =over 4
 
-=item locaitonnum
+=item locationnum
 
 New locationnum, to change the location for this package.
 
@@ -1036,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.
 
@@ -1096,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,
@@ -1137,18 +1300,99 @@ sub change {
     return "Unable to transfer all services from package ". $self->pkgnum;
   }
 
-  #Good to go, cancel old package.
-  $error = $self->cancel( quiet=>1 );
+  #reset usage if changing pkgpart
+  # AND usage rollover is off (otherwise adds twice, now and at package bill)
+  if ($self->pkgpart != $cust_pkg->pkgpart) {
+    my $part_pkg = $cust_pkg->part_pkg;
+    $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
+                                                 ? ()
+                                                 : ( 'null' => 1 )
+                                   )
+      if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
+
+    if ($error) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error setting usage values: $error";
+    }
+  }
+
+  #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;
+    $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
+  if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
+    #$self->cust_main
+    my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
   $cust_pkg;
 
 }
 
+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.
@@ -1209,10 +1453,9 @@ L<FS::part_pkg>).
 
 sub part_pkg {
   my $self = shift;
-  #exists( $self->{'_pkgpart'} )
-  $self->{'_pkgpart'}
-    ? $self->{'_pkgpart'}
-    : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
+  return $self->{'_pkgpart'} if $self->{'_pkgpart'};
+  cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
+  qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
 }
 
 =item old_cust_pkg
@@ -1251,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
@@ -1402,11 +1657,15 @@ services.
 sub cust_svc {
   my $self = shift;
 
+  return () unless $self->num_cust_svc(@_);
+
   if ( @_ ) {
     return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
                                   'svcpart' => shift,          } );
   }
 
+  cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
+
   #if ( $self->{'_svcnum'} ) {
   #  values %{ $self->{'_svcnum'}->cache };
   #} else {
@@ -1427,34 +1686,45 @@ is specified, return only the matching services.
 
 sub overlimit {
   my $self = shift;
-  grep { $_->overlimit } $self->cust_svc;
+  return () unless $self->num_cust_svc(@_);
+  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 {
   my( $self, $arrayref ) = @_;
 
+  my $sort =
+    sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
+
   map  { $_->[0] }
-  sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
+  sort $sort
   map {
         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
                                              'svcpart' => $_->svcpart     } );
@@ -1476,9 +1746,19 @@ specified, counts only the matching services.
 
 sub num_cust_svc {
   my $self = shift;
+
+  return $self->{'_num_cust_svc'}
+    if !scalar(@_)
+       && exists($self->{'_num_cust_svc'})
+       && $self->{'_num_cust_svc'} =~ /\d/;
+
+  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 $sth = dbh->prepare($sql) or die dbh->errstr;
+
+  my $sth = dbh->prepare($sql)     or die  dbh->errstr;
   $sth->execute($self->pkgnum, @_) or die $sth->errstr;
   $sth->fetchrow_arrayref->[0];
 }
@@ -1498,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;
@@ -1535,7 +1822,9 @@ sub part_svc {
     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
     $part_svc->{'Hash'}{'num_avail'}    =
       max( 0, $pkg_svc->quantity - $num_cust_svc );
-    $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
+    $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;
 
@@ -1545,7 +1834,8 @@ sub part_svc {
     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
-    $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
+    $part_svc->{'Hash'}{'cust_pkg_svc'} =
+      $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
     $part_svc;
   } $self->extra_part_svc;
 
@@ -1567,20 +1857,40 @@ sub extra_part_svc {
   my $pkgnum  = $self->pkgnum;
   my $pkgpart = $self->pkgpart;
 
+#  qsearch( {
+#    'table'     => 'part_svc',
+#    'hashref'   => {},
+#    'extra_sql' =>
+#      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
+#                     WHERE pkg_svc.svcpart = part_svc.svcpart 
+#                       AND pkg_svc.pkgpart = ?
+#                       AND quantity > 0 
+#                 )
+#       AND 0 < ( SELECT COUNT(*) FROM cust_svc
+#                       LEFT JOIN cust_pkg USING ( pkgnum )
+#                     WHERE cust_svc.svcpart = part_svc.svcpart
+#                       AND pkgnum = ?
+#                 )",
+#    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
+#  } );
+
+#seems to benchmark slightly faster...
   qsearch( {
-    'table'     => 'part_svc',
-    'hashref'   => {},
-    'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
-                                  WHERE pkg_svc.svcpart = part_svc.svcpart 
-                                   AND pkg_svc.pkgpart = $pkgpart
-                                   AND quantity > 0 
-                             )
-                     AND 0 < ( SELECT count(*)
-                                 FROM cust_svc
-                                   LEFT JOIN cust_pkg using ( pkgnum )
-                                 WHERE cust_svc.svcpart = part_svc.svcpart
-                                   AND pkgnum = $pkgnum
-                             )",
+    #'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   = ?
+                               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'] ],
   } );
 }
 
@@ -1616,6 +1926,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
@@ -1626,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',
@@ -1635,8 +1955,8 @@ tie my %statuscolor, 'Tie::IxHash',
 
 sub statuses {
   my $self = shift; #could be class...
-  grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
-                                      # mayble split btw one-time vs. recur
+  #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
+  #                                    # mayble split btw one-time vs. recur
     keys %statuscolor;
 }
 
@@ -1651,6 +1971,63 @@ sub statuscolor {
   $statuscolor{$self->status};
 }
 
+=item pkg_label
+
+Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
+"pkg-comment" depending on user preference).
+
+=cut
+
+sub pkg_label {
+  my $self = shift;
+  my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
+  $label = $self->pkgnum. ": $label"
+    if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
+  $label;
+}
+
+=item pkg_label_long
+
+Returns a long label for this package, adding the primary service's label to
+pkg_label.
+
+=cut
+
+sub pkg_label_long {
+  my $self = shift;
+  my $label = $self->pkg_label;
+  my $cust_svc = $self->primary_cust_svc;
+  $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
+  $label;
+}
+
+=item primary_cust_svc
+
+Returns a primary service (as FS::cust_svc object) if one can be identified.
+
+=cut
+
+#for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
+
+sub primary_cust_svc {
+  my $self = shift;
+
+  my @cust_svc = $self->cust_svc;
+
+  return '' unless @cust_svc; #no serivces - irrelevant then
+  
+  return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
+
+  # primary service as specified in the package definition
+  # or exactly one service definition with quantity one
+  my $svcpart = $self->part_pkg->svcpart;
+  @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
+  return $cust_svc[0] if scalar(@cust_svc) == 1;
+
+  #couldn't identify one thing..
+  return '';
+}
+
 =item labels
 
 Returns a list of lists, calling the label method for all services
@@ -1663,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.
@@ -1679,6 +2057,19 @@ sub h_labels {
   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
 }
 
+=item labels_short
+
+Like labels, except returns a simple flat list, and shortens long
+(currently >5 or the cust_bill-max_same_services configuration value) lists of
+identical services to one line that lists the service label and the number of
+individual services rather than individual items.
+
+=cut
+
+sub labels_short {
+  shift->_labels_short( 'labels', @_ );
+}
+
 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
 
 Like h_labels, except returns a simple flat list, and shortens long
@@ -1689,7 +2080,11 @@ individual services rather than individual items.
 =cut
 
 sub h_labels_short {
-  my $self = shift;
+  shift->_labels_short( 'h_labels', @_ );
+}
+
+sub _labels_short {
+  my( $self, $method ) = ( shift, shift );
 
   my $conf = new FS::Conf;
   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
@@ -1697,7 +2092,7 @@ sub h_labels_short {
   my %labels;
   #tie %labels, 'Tie::IxHash';
   push @{ $labels{$_->[0]} }, $_->[1]
-    foreach $self->h_labels(@_);
+    foreach $self->$method(@_);
   my @labels;
   foreach my $label ( keys %labels ) {
     my %seen = ();
@@ -1706,7 +2101,18 @@ sub h_labels_short {
     if ( $num > $max_same_services ) {
       push @labels, "$label ($num)";
     } else {
-      push @labels, map { "$label: $_" } @values;
+      if ( $conf->exists('cust_bill-consolidate_services') ) {
+        # push @labels, "$label: ". join(', ', @values);
+        while ( @values ) {
+          my $detail = "$label: ";
+          $detail .= shift(@values). ', '
+            while @values && length($detail.$values[0]) < 78;
+          $detail =~ s/, $//;
+          push @labels, $detail;
+        }
+      } else {
+        push @labels, map { "$label: $_" } @values;
+      }
     }
   }
 
@@ -1725,29 +2131,24 @@ sub cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
+#these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
+
 =item cust_location
 
 Returns the location object, if any (see L<FS::cust_location>).
 
-=cut
-
-sub cust_location {
-  my $self = shift;
-  return '' unless $self->locationnum;
-  qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
-}
-
 =item cust_location_or_main
 
 If this package is associated with a location, returns the locaiton (see
 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
 
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the location object (see L<FS::cust_location>).
+
 =cut
 
-sub cust_location_or_main {
-  my $self = shift;
-  $self->cust_location || $self->cust_main;
-}
+#end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
 
 =item seconds_since TIMESTAMP
 
@@ -1999,73 +2400,268 @@ sub reexport {
 
 }
 
-=back
+=item insert_reason
 
-=head1 CLASS METHODS
+Associates this package with a (suspension or cancellation) reason (see
+L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
+L<FS::reason>).
+
+Available options are:
 
 =over 4
 
-=item recurring_sql
+=item reason
 
-Returns an SQL expression identifying recurring packages.
+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.
 
-=cut
+=item reason_otaker
 
-sub recurring_sql { "
-  '0' != ( select freq from part_pkg
-             where cust_pkg.pkgpart = part_pkg.pkgpart )
-"; }
+the access_user (see L<FS::access_user>) providing the reason
 
-=item onetime_sql
+=item date
 
-Returns an SQL expression identifying one-time packages.
+a unix timestamp 
 
-=cut
+=item action
 
-sub onetime_sql { "
-  '0' = ( select freq from part_pkg
-            where cust_pkg.pkgpart = part_pkg.pkgpart )
-"; }
+the action (cancel, susp, adjourn, expire) associated with the reason
 
-=item active_sql
+=back
 
-Returns an SQL expression identifying active packages.
+If there is an error, returns the error, otherwise returns false.
 
 =cut
 
-sub active_sql { "
-  ". $_[0]->recurring_sql(). "
-  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-  AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
-"; }
+sub insert_reason {
+  my ($self, %options) = @_;
 
-=item inactive_sql
+  my $otaker = $options{reason_otaker} ||
+               $FS::CurrentUser::CurrentUser->username;
 
-Returns an SQL expression identifying inactive packages (one-time packages
-that are otherwise unsuspended/uncancelled).
+  my $reasonnum;
+  if ( $options{'reason'} =~ /^(\d+)$/ ) {
 
-=cut
+    $reasonnum = $1;
 
-sub inactive_sql { "
-  ". $_[0]->onetime_sql(). "
-  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-  AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
-"; }
+  } elsif ( ref($options{'reason'}) ) {
+  
+    return 'Enter a new reason (or select an existing one)'
+      unless $options{'reason'}->{'reason'} !~ /^\s*$/;
 
-=item susp_sql
-=item suspended_sql
+    my $reason = new FS::reason({
+      'reason_type' => $options{'reason'}->{'typenum'},
+      'reason'      => $options{'reason'}->{'reason'},
+    });
+    my $error = $reason->insert;
+    return $error if $error;
 
-Returns an SQL expression identifying suspended packages.
+    $reasonnum = $reason->reasonnum;
 
-=cut
+  } else {
+    return "Unparsable reason: ". $options{'reason'};
+  }
 
-sub suspended_sql { susp_sql(@_); }
-sub susp_sql {
-  #$_[0]->recurring_sql(). ' AND '.
-  "
-        ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
-    AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
-  ";
+  my $cust_pkg_reason =
+    new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
+                              'reasonnum' => $reasonnum, 
+                             'otaker'    => $otaker,
+                             'action'    => substr(uc($options{'action'}),0,1),
+                             'date'      => $options{'date'}
+                                              ? $options{'date'}
+                                              : time,
+                           });
+
+  $cust_pkg_reason->insert;
+}
+
+=item insert_discount
+
+Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
+inserting a new discount on the fly (see L<FS::discount>).
+
+Available options are:
+
+=over 4
+
+=item discountnum
+
+=back
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub insert_discount {
+  #my ($self, %options) = @_;
+  my $self = shift;
+
+  my $cust_pkg_discount = new FS::cust_pkg_discount {
+    'pkgnum'      => $self->pkgnum,
+    '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,
+    #'disabled'    => $self->discountnum_disabled,
+  };
+
+  $cust_pkg_discount->insert;
+}
+
+=item set_usage USAGE_VALUE_HASHREF 
+
+USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
+to which they should be set (see L<FS::svc_acct>).  Currently seconds,
+upbytes, downbytes, and totalbytes are appropriate keys.
+
+All svc_accts which are part of this package have their values reset.
+
+=cut
+
+sub set_usage {
+  my ($self, $valueref, %opt) = @_;
+
+  foreach my $cust_svc ($self->cust_svc){
+    my $svc_x = $cust_svc->svc_x;
+    $svc_x->set_usage($valueref, %opt)
+      if $svc_x->can("set_usage");
+  }
+}
+
+=item recharge USAGE_VALUE_HASHREF 
+
+USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
+to which they should be set (see L<FS::svc_acct>).  Currently seconds,
+upbytes, downbytes, and totalbytes are appropriate keys.
+
+All svc_accts which are part of this package have their values incremented.
+
+=cut
+
+sub recharge {
+  my ($self, $valueref) = @_;
+
+  foreach my $cust_svc ($self->cust_svc){
+    my $svc_x = $cust_svc->svc_x;
+    $svc_x->recharge($valueref)
+      if $svc_x->can("recharge");
+  }
+}
+
+=item cust_pkg_discount
+
+=cut
+
+sub cust_pkg_discount {
+  my $self = shift;
+  qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
+}
+
+=item cust_pkg_discount_active
+
+=cut
+
+sub cust_pkg_discount_active {
+  my $self = shift;
+  grep { $_->status eq 'active' } $self->cust_pkg_discount;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item recurring_sql
+
+Returns an SQL expression identifying recurring packages.
+
+=cut
+
+sub recurring_sql { "
+  '0' != ( select freq from part_pkg
+             where cust_pkg.pkgpart = part_pkg.pkgpart )
+"; }
+
+=item onetime_sql
+
+Returns an SQL expression identifying one-time packages.
+
+=cut
+
+sub onetime_sql { "
+  '0' = ( select freq from part_pkg
+            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. "
+  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 )
+"; }
+
+=item not_yet_billed_sql
+
+Returns an SQL expression identifying packages which have not yet been billed.
+
+=cut
+
+sub not_yet_billed_sql { "
+      ( cust_pkg.setup  IS NULL OR 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 )
+"; }
+
+=item inactive_sql
+
+Returns an SQL expression identifying inactive packages (one-time packages
+that are otherwise unsuspended/uncancelled).
+
+=cut
+
+sub inactive_sql { "
+  ". $_[0]->onetime_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 )
+"; }
+
+=item susp_sql
+=item suspended_sql
+
+Returns an SQL expression identifying suspended packages.
+
+=cut
+
+sub suspended_sql { susp_sql(@_); }
+sub susp_sql {
+  #$_[0]->recurring_sql(). ' AND '.
+  "
+        ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
+    AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
+  ";
 }
 
 =item cancel_sql
@@ -2081,7 +2677,23 @@ sub cancel_sql {
   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
 }
 
-=item search_sql HASHREF
+=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)
 
@@ -2100,11 +2712,15 @@ active, inactive, suspended, cancel (or cancelled)
 
 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
 
+=item custom
+
+ boolean selects custom packages
+
 =item classnum
 
 =item pkgpart
 
-list specified how?
+pkgpart or arrayref or hashref of pkgparts
 
 =item setup
 
@@ -2146,11 +2762,15 @@ a value suited to passing to FS::UI::Web::cust_header
 
 specifies the user for agent virtualization
 
+=item fcc_line
+
+ boolean selects packages containing fcc form 477 telco lines
+
 =back
 
 =cut
 
-sub search_sql { 
+sub search {
   my ($class, $params) = @_;
   my @where = ();
 
@@ -2164,6 +2784,24 @@ sub search_sql {
   }
 
   ##
+  # parse custnum
+  ##
+
+  if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_pkg.custnum = $1";
+  }
+
+  ##
+  # custbatch
+  ##
+
+  if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+    push @where,
+      "cust_pkg.pkgbatch = '$1'";
+  }
+
+  ##
   # parse status
   ##
 
@@ -2172,8 +2810,13 @@ sub search_sql {
 
     push @where, FS::cust_pkg->active_sql();
 
-  } elsif (    $params->{'magic'}  eq 'inactive'
-            || $params->{'status'} eq 'inactive' ) {
+  } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
+            || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
+
+    push @where, FS::cust_pkg->not_yet_billed_sql();
+
+  } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
+            || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
 
     push @where, FS::cust_pkg->inactive_sql();
 
@@ -2187,10 +2830,6 @@ sub search_sql {
 
     push @where, FS::cust_pkg->cancelled_sql();
 
-  } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
-
-    push @where, FS::cust_pkg->inactive_sql();
-
   }
 
   ###
@@ -2206,7 +2845,7 @@ sub search_sql {
   {
     $classnum = $1;
     if ( $classnum ) { #a specific class
-      push @where, "classnum = $classnum";
+      push @where, "part_pkg.classnum = $classnum";
 
       #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
       #die "classnum $classnum not found!" unless $pkg_class[0];
@@ -2214,7 +2853,7 @@ sub search_sql {
 
     } elsif ( $classnum eq '' ) { #the empty class
 
-      push @where, "classnum IS NULL";
+      push @where, "part_pkg.classnum IS NULL";
       #$title .= 'Empty class ';
       #@pkg_class = ( '(empty class)' );
     } elsif ( $classnum eq '0' ) {
@@ -2227,12 +2866,74 @@ sub search_sql {
   #eslaf
 
   ###
+  # parse package report options
+  ###
+
+  my @report_option = ();
+  if ( exists($params->{'report_option'})
+       && $params->{'report_option'} =~ /^([,\d]*)$/
+     )
+  {
+    @report_option = split(',', $1);
+  }
+
+  if (@report_option) {
+    # this will result in the empty set for the dangling comma case as it should
+    push @where, 
+      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;
+  }
+
+  #eslaf
+
+  ###
+  # parse custom
+  ###
+
+  push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
+
+  ###
+  # parse fcc_line
+  ###
+
+  push @where,  "part_pkg.fcc_ds0s > 0" if $params->{fcc_line};
+
+  ###
+  # parse censustract
+  ###
+
+  if ( exists($params->{'censustract'}) ) {
+    $params->{'censustract'} =~ /^([.\d]*)$/;
+    my $censustract = "cust_main.censustract = '$1'";
+    $censustract .= ' OR cust_main.censustract is NULL' unless $1;
+    push @where,  "( $censustract )";
+  }
+
+  ###
   # parse part_pkg
   ###
 
-  my $pkgpart = join (' OR pkgpart=',
-                      grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
-  push @where,  '(pkgpart=' . $pkgpart . ')' if $pkgpart;
+  if ( ref($params->{'pkgpart'}) ) {
+
+    my @pkgpart = ();
+    if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
+      @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
+    } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
+      @pkgpart = @{ $params->{'pkgpart'} };
+    } else {
+      die 'unhandled pkgpart ref '. $params->{'pkgpart'};
+    }
+
+    @pkgpart = grep /^(\d+)$/, @pkgpart;
+
+    push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
+
+  } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
+    push @where, "pkgpart = $1";
+  } 
 
   ###
   # parse dates
@@ -2250,21 +2951,32 @@ sub search_sql {
     ''                => {},
   );
 
-  foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
+  if( exists($params->{'active'} ) ) {
+    # This overrides all the other date-related fields
+    my($beginning, $ending) = @{$params->{'active'}};
+    push @where,
+      "cust_pkg.setup IS NOT NULL",
+      "cust_pkg.setup <= $ending",
+      "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
+      "NOT (".FS::cust_pkg->onetime_sql . ")";
+  }
+  else {
+    foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
 
-    next unless exists($params->{$field});
+      next unless exists($params->{$field});
 
-    my($beginning, $ending) = @{$params->{$field}};
+      my($beginning, $ending) = @{$params->{$field}};
 
-    next if $beginning == 0 && $ending == 4294967295;
+      next if $beginning == 0 && $ending == 4294967295;
 
-    push @where,
-      "cust_pkg.$field IS NOT NULL",
-      "cust_pkg.$field >= $beginning",
-      "cust_pkg.$field <= $ending";
+      push @where,
+        "cust_pkg.$field IS NOT NULL",
+        "cust_pkg.$field >= $beginning",
+        "cust_pkg.$field <= $ending";
 
-    $orderby ||= "ORDER BY cust_pkg.$field";
+      $orderby ||= "ORDER BY cust_pkg.$field";
 
+    }
   }
 
   $orderby ||= 'ORDER BY bill';
@@ -2313,10 +3025,10 @@ sub search_sql {
 
     if ($access_user) {
       push @where, $access_user->agentnums_sql('table'=>'cust_main');
-    }else{
+    } else {
       push @where, "1=0";
     }
-  }else{
+  } else {
     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
   }
 
@@ -2324,7 +3036,7 @@ sub search_sql {
 
   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
-                  'LEFT JOIN pkg_class USING ( classnum ) ';
+                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
 
   my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
 
@@ -2335,7 +3047,7 @@ sub search_sql {
                                 'cust_pkg.*',
                                 ( map "part_pkg.$_", qw( pkg freq ) ),
                                 'pkg_class.classname',
-                                'cust_main.custnum as cust_main_custnum',
+                                'cust_main.custnum AS cust_main_custnum',
                                 FS::UI::Web::cust_sql_fields(
                                   $params->{'cust_fields'}
                                 ),
@@ -2347,6 +3059,35 @@ sub search_sql {
 
 }
 
+=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 
@@ -2363,13 +3104,8 @@ sub location_sql {
   my $conf = new FS::Conf;
 
   # '?' placeholders in _location_sql_where
-  my @bill_param;
-  if ( $ornull ) {
-    @bill_param = qw( county county state state state country );
-  } else {
-    @bill_param = qw( county state state country );
-  }
-  unshift @bill_param, 'county'; # unless $nec;
+  my $x = $ornull ? 3 : 2;
+  my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
 
   my $main_where;
   my @main_param;
@@ -2424,13 +3160,18 @@ sub _location_sql_where {
   my $prefix = @_ ? shift : '';
   my $ornull = @_ ? shift : '';
 
+#  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
+
   $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 ) ";
 
+#        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
   "
-        ( $table.${prefix}county  = ? $or_empty_county $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 = ?
   ";
@@ -2486,6 +3227,9 @@ sub order {
 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
 #  return "Customer not found: $custnum" unless $cust_main;
 
+  warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
+    if $DEBUG;
+
   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
                          @$remove_pkgnum;
 
@@ -2494,6 +3238,10 @@ sub order {
   my %hash = (); 
   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
 
+    warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
+         " to pkgpart ". $pkgparts->[0]. "\n"
+      if $DEBUG;
+
     my $err_or_cust_pkg =
       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
                                 'refnum'  => $refnum,
@@ -2505,12 +3253,16 @@ sub order {
     }
 
     push @$return_cust_pkg, $err_or_cust_pkg;
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
     return '';
 
   }
 
   # Create the new packages.
   foreach my $pkgpart (@$pkgparts) {
+
+    warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
+
     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
                                       pkgpart => $pkgpart,
                                       refnum  => $refnum,
@@ -2529,6 +3281,9 @@ sub order {
   # Transfer services and cancel old packages.
   foreach my $old_pkg (@old_cust_pkg) {
 
+    warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
+      if $DEBUG;
+
     foreach my $new_pkg (@$return_cust_pkg) {
       $error = $old_pkg->transfer($new_pkg);
       if ($error and $error == 0) {
@@ -2627,117 +3382,24 @@ sub bulk_change {
   '';
 }
 
-=item insert_reason
-
-Associates this package with a (suspension or cancellation) reason (see
-L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
-L<FS::reason>).
-
-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_otaker
-
-the access_user (see L<FS::access_user>) providing the reason
-
-=item date
-
-a unix timestamp 
-
-=item action
-
-the action (cancel, susp, adjourn, expire) associated with the reason
-
-=back
-
-If there is an error, returns the error, otherwise returns false.
-
-=cut
-
-sub insert_reason {
-  my ($self, %options) = @_;
-
-  my $otaker = $options{reason_otaker} ||
-               $FS::CurrentUser::CurrentUser->username;
-
-  my $reasonnum;
-  if ( $options{'reason'} =~ /^(\d+)$/ ) {
-
-    $reasonnum = $1;
-
-  } elsif ( ref($options{'reason'}) ) {
-  
-    return 'Enter a new reason (or select an existing one)'
-      unless $options{'reason'}->{'reason'} !~ /^\s*$/;
-
-    my $reason = new FS::reason({
-      'reason_type' => $options{'reason'}->{'typenum'},
-      'reason'      => $options{'reason'}->{'reason'},
-    });
-    my $error = $reason->insert;
-    return $error if $error;
-
-    $reasonnum = $reason->reasonnum;
-
-  } else {
-    return "Unparsable reason: ". $options{'reason'};
-  }
-
-  my $cust_pkg_reason =
-    new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
-                              'reasonnum' => $reasonnum, 
-                             'otaker'    => $otaker,
-                             'action'    => substr(uc($options{'action'}),0,1),
-                             'date'      => $options{'date'}
-                                              ? $options{'date'}
-                                              : time,
-                           });
-
-  $cust_pkg_reason->insert;
-}
-
-=item set_usage USAGE_VALUE_HASHREF 
-
-USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
-to which they should be set (see L<FS::svc_acct>).  Currently seconds,
-upbytes, downbytes, and totalbytes are appropriate keys.
-
-All svc_accts which are part of this package have their values reset.
-
-=cut
-
-sub set_usage {
-  my ($self, $valueref) = @_;
-
-  foreach my $cust_svc ($self->cust_svc){
-    my $svc_x = $cust_svc->svc_x;
-    $svc_x->set_usage($valueref)
-      if $svc_x->can("set_usage");
-  }
-}
-
-=item recharge USAGE_VALUE_HASHREF 
-
-USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
-to which they should be set (see L<FS::svc_acct>).  Currently seconds,
-upbytes, downbytes, and totalbytes are appropriate keys.
-
-All svc_accts which are part of this package have their values incremented.
-
-=cut
-
-sub recharge {
-  my ($self, $valueref) = @_;
-
-  foreach my $cust_svc ($self->cust_svc){
-    my $svc_x = $cust_svc->svc_x;
-    $svc_x->recharge($valueref)
-      if $svc_x->can("recharge");
+# Used by FS::Upgrade to migrate to a new database.
+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;
   }
 }