merge NG auth, RT#21563
[freeside.git] / FS / FS / cust_pkg.pm
index 25985ce..4dced54 100644 (file)
@@ -1,30 +1,40 @@
 package FS::cust_pkg;
 
 use strict;
-use vars qw(@ISA $disable_agentcheck $DEBUG);
+use base qw( FS::otaker_Mixin FS::cust_main_Mixin
+             FS::contact_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 List::Util qw(min max);
 use Tie::IxHash;
-use FS::UID qw( getotaker dbh );
+use Time::Local qw( timelocal timelocal_nocheck );
+use MIME::Entity;
+use FS::UID qw( dbh driver_name );
 use FS::Misc qw( send_email );
-use FS::Record qw( qsearch qsearchs );
-use FS::m2m_Common;
-use FS::cust_main_Mixin;
+use FS::Record qw( qsearch qsearchs fields );
+use FS::CurrentUser;
 use FS::cust_svc;
 use FS::part_pkg;
 use FS::cust_main;
+use FS::contact;
 use FS::cust_location;
-use FS::type_pkgs;
 use FS::pkg_svc;
 use FS::cust_bill_pkg;
 use FS::cust_pkg_detail;
+use FS::cust_pkg_usage;
+use FS::cdr_cust_pkg_usage;
 use FS::cust_event;
 use FS::h_cust_svc;
 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;
+use Data::Dumper;
 
 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
 # setup }
@@ -37,9 +47,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;
 
@@ -121,6 +130,14 @@ 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
+
 =item setup
 
 date
@@ -145,13 +162,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
 
@@ -174,6 +195,21 @@ Previous pkgnum
 
 Previous pkgpart
 
+=item change_locationnum
+
+Previous locationnum
+
+=item waive_setup
+
+=item main_pkgnum
+
+The pkgnum of the package that this package is supplemental to, if any.
+
+=item pkglinknum
+
+The package link (L<FS::part_pkg_link>) that defines this supplemental
+package, if it is one.
+
 =back
 
 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
@@ -191,7 +227,7 @@ Create a new billing item.  To add the item to the database, see L<"insert">.
 =cut
 
 sub table { 'cust_pkg'; }
-sub cust_linked { $_[0]->cust_main_custnum; } 
+sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } 
 sub cust_unlinked_msg {
   my $self = shift;
   "WARNING: can't find cust_main.custnum ". $self->custnum.
@@ -225,6 +261,20 @@ 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
+
+=item allow_pkgpart
+
+Don't check the legality of the package definition.  This should be used
+when performing a package change that doesn't change the pkgpart (i.e. 
+a location change).
+
 =back
 
 =cut
@@ -232,6 +282,37 @@ cust_pkg_option records will be created
 sub insert {
   my( $self, %options ) = @_;
 
+  my $error;
+  $error = $self->check_pkgpart unless $options{'allow_pkgpart'};
+  return $error if $error;
+
+  my $part_pkg = $self->part_pkg;
+
+  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) );
+  }
+
+  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) );
+    }
+  }
+
+  my $free_days = $part_pkg->option('free_days',1);
+  if ( $free_days && $part_pkg->option('delay_setup',1) ) { #&& !$self->start_date
+    my ($mday,$mon,$year) = (localtime(time) )[3,4,5];
+    #my $start_date = ($self->start_date || timelocal(0,0,0,$mday,$mon,$year)) + 86400 * $free_days;
+    my $start_date = timelocal(0,0,0,$mday,$mon,$year) + 86400 * $free_days;
+    $self->start_date($start_date);
+  }
+
+  $self->order_date(time);
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -243,7 +324,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;
@@ -256,6 +337,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;
@@ -267,6 +356,36 @@ sub insert {
 
   my $conf = new FS::Conf;
 
+  if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
+
+    #this init stuff is still inefficient, but at least its limited to 
+    # the small number (any?) folks using ticket emailing on pkg order
+
+    #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',
@@ -291,14 +410,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 ... ]
 
@@ -350,7 +525,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!"
@@ -413,7 +588,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;
@@ -436,22 +614,68 @@ replace methods.
 sub check {
   my $self = shift;
 
-  $self->locationnum('') if $self->locationnum == 0 || $self->locationnum == -1;
+  if ( !$self->locationnum or $self->locationnum == -1 ) {
+    $self->set('locationnum', $self->cust_main->ship_locationnum);
+  }
 
   my $error = 
     $self->ut_numbern('pkgnum')
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_numbern('pkgpart')
+    || $self->ut_foreign_keyn('contactnum',  'contact',       'contactnum' )
     || $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('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', ])
+    || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum')
   ;
   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
+
+Check the pkgpart to make sure it's allowed with the reg_code and/or
+promo_code of the package (if present) and with the customer's agent.
+Called from C<insert>, unless we are doing a package change that doesn't
+affect pkgpart.
+
+=cut
+
+sub check_pkgpart {
+  my $self = shift;
+
+  # my $error = $self->ut_numbern('pkgpart'); # already done
+
+  my $error;
   if ( $self->reg_code ) {
 
     unless ( grep { $self->pkgpart == $_->pkgpart }
@@ -476,10 +700,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' );
@@ -487,18 +711,8 @@ sub check {
 
   }
 
-  $self->otaker(getotaker) unless $self->otaker;
-  $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
-  $self->otaker($1);
-
-  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 ... ]
@@ -513,11 +727,27 @@ Available options are:
 
 =item quiet - can be set true to supress email cancellation notices.
 
-=item time -  can be set to cancel the package based on a specific future or historical date.  Using time ensures that the remaining amount is calculated correctly.  Note however that this is an immediate cancel and just changes the date.  You are PROBABLY looking to expire the account instead of using this.
+=item time -  can be set to cancel the package based on a specific future or 
+historical date.  Using time ensures that the remaining amount is calculated 
+correctly.  Note however that this is an immediate cancel and just changes 
+the date.  You are PROBABLY looking to expire the account instead of using 
+this.
 
-=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
+=item reason - can be set to a cancellation reason (see L<FS:reason>), 
+either a reasonnum of an existing reason, or passing a hashref will create 
+a new reason.  The hashref should have the following keys: typenum - Reason 
+type (see L<FS::reason_type>, reason - Text of the new reason.
 
-=item date - can be set to a unix style timestamp to specify when to cancel (expire)
+=item date - can be set to a unix style timestamp to specify when to 
+cancel (expire)
+
+=item nobill - can be set true to skip billing if it might otherwise be done.
+
+=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
 
@@ -529,6 +759,13 @@ sub cancel {
   my( $self, %options ) = @_;
   my $error;
 
+  # pass all suspend/cancel actions to the main package
+  if ( $self->main_pkgnum and !$options{'from_main'} ) {
+    return $self->main_pkg->cancel(%options);
+  }
+
+  my $conf = new FS::Conf;
+
   warn "cust_pkg::cancel called with options".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
     if $DEBUG;
@@ -551,10 +788,26 @@ sub cancel {
     return "";  # no error
   }
 
-  my $date = $options{date} if $options{date}; # expire/cancel later
-  $date = '' if ($date && $date <= time);      # complain instead?
-
+  # XXX possibly set cancel_time to the expire date?
   my $cancel_time = $options{'time'} || time;
+  my $date = $options{'date'} if $options{'date'}; # expire/cancel later
+  $date = '' if ($date && $date <= $cancel_time);      # complain instead?
+
+  #race condition: usage could be ongoing until unprovisioned
+  #resolved by performing a change package instead (which unprovisions) and
+  #later cancelling
+  if ( !$options{nobill} && !$date ) {
+    # && $conf->exists('bill_usage_on_cancel') ) { #calc_cancel checks this
+      my $copy = $self->new({$self->hash});
+      my $error =
+        $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
+                                'cancel'   => 1,
+                                'time'     => $cancel_time );
+      warn "Error billing during cancel, custnum ".
+        #$self->cust_main->custnum. ": $error"
+        ": $error"
+        if $error;
+  }
 
   if ( $options{'reason'} ) {
     $error = $self->insert_reason( 'reason' => $options{'reason'},
@@ -568,43 +821,48 @@ 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";
     }
+  }
 
-    # 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;
-      my $error = $self->cust_main->credit(
-        $remaining_value,
-        'Credit for unused time on '. $self->part_pkg->pkg,
-        'reason_type' => $conf->config('cancel_credit_type'),
-      );
+  unless ($date) {
+    # credit remaining time if appropriate
+    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 ) {
+      my $error = $self->credit_remaining('cancel', $cancel_time);
       if ($error) {
         $dbh->rollback if $oldAutoCommit;
-        return "Error crediting customer \$$remaining_value for unused time on".
-               $self->part_pkg->pkg. ": $error";
+        return $error;
       }
     }
-  }
+
+  } #unless $date
 
   my %hash = $self->hash;
   $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
+  $hash{'change_custnum'} = $options{'change_custnum'};
   my $new = new FS::cust_pkg ( \%hash );
   $error = $new->replace( $self, options => { $self->options } );
   if ( $error ) {
@@ -612,19 +870,44 @@ sub cancel {
     return $error;
   }
 
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    $error = $supp_pkg->cancel(%options, 'from_main' => 1);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
+  }
+
+  foreach my $usage ( $self->cust_pkg_usage ) {
+    $error = $usage->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "deleting usage pools: $error";
+    }
+  }
+
   $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'),
-      '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?
   }
 
@@ -650,6 +933,186 @@ 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
+
+main_pkgnum: link the package as a supplemental package of this one.  For 
+internal use only.
+
+=cut
+
+sub uncancel {
+  my( $self, %options ) = @_;
+
+  #in case you try do do $uncancel-date = $cust_pkg->uncacel 
+  return '' unless $self->get('cancel');
+
+  if ( $self->main_pkgnum and !$options{'main_pkgnum'} ) {
+    return $self->main_pkg->uncancel(%options);
+  }
+
+  ##
+  # 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,
+    main_pkgnum     => ($options{'main_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
+    'allow_pkgpart' => 1, # allow this even if the package def is disabled
+  );
+  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;
+    }
+  }
+
+  ##
+  # Uncancel any supplemental packages, and make them supplemental to the 
+  # new one.
+  ##
+
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    my $new_pkg;
+    $error = $supp_pkg->uncancel(%options, 'main_pkgnum' => $cust_pkg->pkgnum);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $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).
@@ -711,9 +1174,24 @@ 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 time - can be set to override the current time, for calculation 
+of final invoices or unused-time credits
 
-=item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
+=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.
+
+=item from_main - allows a supplemental package to be suspended, rather
+than redirecting the method call to its main package.  For internal use.
 
 =back
 
@@ -725,6 +1203,11 @@ sub suspend {
   my( $self, %options ) = @_;
   my $error;
 
+  # pass all suspend/cancel actions to the main package
+  if ( $self->main_pkgnum and !$options{'from_main'} ) {
+    return $self->main_pkg->suspend(%options);
+  }
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE'; 
@@ -749,15 +1232,32 @@ sub suspend {
     return "";  # no error                     # complain on adjourn?
   }
 
+  my $suspend_time = $options{'time'} || time;
   my $date = $options{date} if $options{date}; # adjourn/suspend later
-  $date = '' if ($date && $date <= time);      # complain instead?
+  $date = '' if ($date && $date <= $suspend_time); # complain instead?
 
   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
     dbh->rollback if $oldAutoCommit;
     return "Package $pkgnum expires before it would be suspended.";
   }
 
-  my $suspend_time = $options{'time'} || time;
+  # some false laziness with sub cancel
+  if ( !$options{nobill} && !$date &&
+       $self->part_pkg->option('bill_suspend_as_cancel',1) ) {
+    # kind of a kludge--'bill_suspend_as_cancel' to avoid having to 
+    # make the entire cust_main->bill path recognize 'suspend' and 
+    # 'cancel' separately.
+    warn "Billing $pkgnum on suspension (at $suspend_time)\n" if $DEBUG;
+    my $copy = $self->new({$self->hash});
+    my $error =
+      $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
+                              'cancel'   => 1,
+                              'time'     => $suspend_time );
+    warn "Error billing during suspend, custnum ".
+      #$self->cust_main->custnum. ": $error"
+      ": $error"
+      if $error;
+  }
 
   if ( $options{'reason'} ) {
     $error = $self->insert_reason( 'reason' => $options{'reason'},
@@ -771,14 +1271,46 @@ sub suspend {
     }
   }
 
-  unless ( $date ) {
+  my %hash = $self->hash;
+  if ( $date ) {
+    $hash{'adjourn'} = $date;
+  } else {
+    $hash{'susp'} = $suspend_time;
+  }
 
-    my @labels = ();
+  my $resume_date = $options{'resume_date'} || 0;
+  if ( $resume_date > ($date || $suspend_time) ) {
+    $hash{'resume'} = $resume_date;
+  }
 
-    foreach my $cust_svc (
-      qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
-    ) {
-      my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
+  $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 ) {
+    # credit remaining time if appropriate
+    if ( $self->part_pkg->option('unused_credit_suspend', 1) ) {
+      my $error = $self->credit_remaining('suspend', $suspend_time);
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+    my @labels = ();
+
+    foreach my $cust_svc (
+      qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
+    ) {
+      my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
 
       $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
         $dbh->rollback if $oldAutoCommit;
@@ -803,7 +1335,8 @@ sub suspend {
     if ( $conf->config('suspend_email_admin') ) {
  
       my $error = send_email(
-        'from'    => $conf->config('invoice_from'), #??? well as good as any
+        'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
+                                   #invoice_from ??? well as good as any
         'to'      => $conf->config('suspend_email_admin'),
         'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
         'body'    => [
@@ -825,17 +1358,12 @@ 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;
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    $error = $supp_pkg->suspend(%options, 'from_main' => 1);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "suspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -843,16 +1371,64 @@ sub suspend {
   ''; #no errors
 }
 
+=item credit_remaining MODE TIME
+
+Generate a credit for this package for the time remaining in the current 
+billing period.  MODE is either "suspend" or "cancel" (determines the 
+credit type).  TIME is the time of suspension/cancellation.  Both arguments
+are mandatory.
+
+=cut
+
+sub credit_remaining {
+  # Add a credit for remaining service
+  my ($self, $mode, $time) = @_;
+  die 'credit_remaining requires suspend or cancel' 
+    unless $mode eq 'suspend' or $mode eq 'cancel';
+  die 'no suspend/cancel time' unless $time > 0;
+
+  my $conf = FS::Conf->new;
+  my $reason_type = $conf->config($mode.'_credit_type');
+
+  my $last_bill = $self->getfield('last_bill') || 0;
+  my $next_bill = $self->getfield('bill') || 0;
+  if ( $last_bill > 0         # the package has been billed
+      and $next_bill > 0      # the package has a next bill date
+      and $next_bill >= $time # which is in the future
+  ) {
+    my $remaining_value = $self->calc_remain('time' => $time);
+    if ( $remaining_value > 0 ) {
+      warn "Crediting for $remaining_value on package ".$self->pkgnum."\n"
+        if $DEBUG;
+      my $error = $self->cust_main->credit(
+        $remaining_value,
+        'Credit for unused time on '. $self->part_pkg->pkg,
+        'reason_type' => $reason_type,
+      );
+      return "Error crediting customer \$$remaining_value for unused time".
+        " on ". $self->part_pkg->pkg. ": $error"
+        if $error;
+    } #if $remaining_value
+  } #if $last_bill, etc.
+  '';
+}
+
 =item unsuspend [ OPTION => VALUE ... ]
 
 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
 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
@@ -872,6 +1448,11 @@ sub unsuspend {
   my( $self, %opt ) = @_;
   my $error;
 
+  # pass all suspend/cancel actions to the main package
+  if ( $self->main_pkgnum and !$opt{'from_main'} ) {
+    return $self->main_pkg->unsuspend(%opt);
+  }
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE'; 
@@ -887,15 +1468,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 } )
   ) {
@@ -915,22 +1521,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->config('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 ) {
@@ -938,6 +1556,69 @@ 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";
+    }
+
+  }
+
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    $error = $supp_pkg->unsuspend(%opt, 'from_main' => 1);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "unsuspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ''; #no errors
@@ -988,6 +1669,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 ) {
@@ -1001,854 +1683,1923 @@ sub unadjourn {
 
 }
 
-=item last_bill
-
-Returns the last bill date, or if there is no last bill date, the setup date.
-Useful for billing metered services.
-
-=cut
-
-sub last_bill {
-  my $self = shift;
-  return $self->setfield('last_bill', $_[0]) if @_;
-  return $self->getfield('last_bill') if $self->getfield('last_bill');
-  my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
-                                                  'edate'  => $self->bill,  } );
-  $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
-}
-
-=item last_cust_pkg_reason ACTION
 
-Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
-Returns false if there is no reason or the package is not currenly ACTION'd
-ACTION is one of adjourn, susp, cancel, or expire.
+=item change HASHREF | OPTION => VALUE ... 
 
-=cut
+Changes this package: cancels it and creates a new one, with a different
+pkgpart or locationnum or both.  All services are transferred to the new
+package (no change will be made if this is not possible).
 
-sub last_cust_pkg_reason {
-  my ( $self, $action ) = ( shift, shift );
-  my $date = $self->get($action);
-  qsearchs( {
-              'table' => 'cust_pkg_reason',
-              'hashref' => { 'pkgnum' => $self->pkgnum,
-                             'action' => substr(uc($action), 0, 1),
-                             'date'   => $date,
-                           },
-              'order_by' => 'ORDER BY num DESC LIMIT 1',
-           } );
-}
+Options may be passed as a list of key/value pairs or as a hash reference.
+Options are:
 
-=item last_reason ACTION
+=over 4
 
-Returns the most recent ACTION FS::reason associated with the package.
-Returns false if there is no reason or the package is not currenly ACTION'd
-ACTION is one of adjourn, susp, cancel, or expire.
+=item locationnum
 
-=cut
+New locationnum, to change the location for this package.
 
-sub last_reason {
-  my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
-  $cust_pkg_reason->reason
-    if $cust_pkg_reason;
-}
+=item cust_location
 
-=item part_pkg
+New FS::cust_location object, to create a new location and assign it
+to this package.
 
-Returns the definition for this billing item, as an FS::part_pkg object (see
-L<FS::part_pkg>).
+=item cust_main
 
-=cut
+New FS::cust_main object, to create a new customer and assign the new package
+to it.
 
-sub part_pkg {
-  my $self = shift;
-  #exists( $self->{'_pkgpart'} )
-  $self->{'_pkgpart'}
-    ? $self->{'_pkgpart'}
-    : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
-}
+=item pkgpart
 
-=item old_cust_pkg
+New pkgpart (see L<FS::part_pkg>).
 
-Returns the cancelled package this package was changed from, if any.
+=item refnum
 
-=cut
+New refnum (see L<FS::part_referral>).
 
-sub old_cust_pkg {
-  my $self = shift;
-  return '' unless $self->change_pkgnum;
-  qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
-}
+=item keep_dates
 
-=item calc_setup
+Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
+susp, adjourn, cancel, expire, and contract_end) to the new package.
 
-Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
-item.
+=back
 
-=cut
+At least one of locationnum, cust_location, pkgpart, refnum must be specified 
+(otherwise, what's the point?)
 
-sub calc_setup {
-  my $self = shift;
-  $self->part_pkg->calc_setup($self, @_);
-}
+Returns either the new FS::cust_pkg object or a scalar error.
 
-=item calc_recur
+For example:
 
-Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
-item.
+  my $err_or_new_cust_pkg = $old_cust_pkg->change
 
 =cut
 
-sub calc_recur {
+#some false laziness w/order
+sub change {
   my $self = shift;
-  $self->part_pkg->calc_recur($self, @_);
-}
+  my $opt = ref($_[0]) ? shift : { @_ };
 
-=item calc_remain
+#  my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
+#    
 
-Calls the I<calc_remain> of the FS::part_pkg object associated with this
-billing item.
+  my $conf = new FS::Conf;
 
-=cut
+  # Transactionize this whole mess
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE'; 
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE'; 
+  local $SIG{PIPE} = 'IGNORE'; 
 
-sub calc_remain {
-  my $self = shift;
-  $self->part_pkg->calc_remain($self, @_);
-}
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
 
-=item calc_cancel
+  my $error;
 
-Calls the I<calc_cancel> of the FS::part_pkg object associated with this
-billing item.
+  my %hash = (); 
 
-=cut
+  my $time = time;
 
-sub calc_cancel {
-  my $self = shift;
-  $self->part_pkg->calc_cancel($self, @_);
-}
+  #$hash{$_} = $self->$_() foreach qw( last_bill bill );
+    
+  #$hash{$_} = $self->$_() foreach qw( setup );
 
-=item cust_bill_pkg
+  $hash{'setup'} = $time if $self->setup;
 
-Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
+  $hash{'change_date'} = $time;
+  $hash{"change_$_"}  = $self->$_()
+    foreach qw( pkgnum pkgpart locationnum );
 
-=cut
+  if ( $opt->{'cust_location'} ) {
+    $error = $opt->{'cust_location'}->find_or_insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "inserting cust_location (transaction rolled back): $error";
+    }
+    $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
+  }
 
-sub cust_bill_pkg {
-  my $self = shift;
-  qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
-}
+  # whether to override pkgpart checking on the new package
+  my $same_pkgpart = 1;
+  if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
+    $same_pkgpart = 0;
+  }
 
-=item cust_pkg_detail [ DETAILTYPE ]
+  my $unused_credit = 0;
+  my $keep_dates = $opt->{'keep_dates'};
+  # Special case.  If the pkgpart is changing, and the customer is
+  # going to be credited for remaining time, don't keep setup, bill, 
+  # or last_bill dates, and DO pass the flag to cancel() to credit 
+  # the customer.
+  if ( $opt->{'pkgpart'} 
+       and $opt->{'pkgpart'} != $self->pkgpart
+       and $self->part_pkg->option('unused_credit_change', 1) ) {
+    $unused_credit = 1;
+    $keep_dates = 0;
+    $hash{$_} = '' foreach qw(setup bill last_bill);
+  }
 
-Returns any customer package details for this package (see
-L<FS::cust_pkg_detail>).
+  if ( $keep_dates ) {
+    foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
+                          resume start_date contract_end ) ) {
+      $hash{$date} = $self->getfield($date);
+    }
+  }
+  # allow $opt->{'locationnum'} = '' to specifically set it to null
+  # (i.e. customer default location)
+  $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
+
+  # usually this doesn't matter.  the two cases where it does are:
+  # 1. unused_credit_change + pkgpart change + setup fee on the new package
+  # and
+  # 2. (more importantly) changing a package before it's billed
+  $hash{'waive_setup'} = $self->waive_setup;
+
+  my $custnum = $self->custnum;
+  if ( $opt->{cust_main} ) {
+    my $cust_main = $opt->{cust_main};
+    unless ( $cust_main->custnum ) { 
+      my $error = $cust_main->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "inserting cust_main (transaction rolled back): $error";
+      }
+    }
+    $custnum = $cust_main->custnum;
+  }
 
-DETAILTYPE can be set to "I" for invoice details or "C" for comments.
+  $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
 
-=cut
+  # Create the new package.
+  my $cust_pkg = new FS::cust_pkg {
+    custnum        => $custnum,
+    pkgpart        => ( $opt->{'pkgpart'}     || $self->pkgpart      ),
+    refnum         => ( $opt->{'refnum'}      || $self->refnum       ),
+    locationnum    => ( $opt->{'locationnum'}                        ),
+    %hash,
+  };
+  $error = $cust_pkg->insert( 'change' => 1,
+                              'allow_pkgpart' => $same_pkgpart );
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
 
-sub cust_pkg_detail {
-  my $self = shift;
-  my %hash = ( 'pkgnum' => $self->pkgnum );
-  $hash{detailtype} = shift if @_;
-  qsearch({
-    'table'    => 'cust_pkg_detail',
-    'hashref'  => \%hash,
-    'order_by' => 'ORDER BY weight, pkgdetailnum',
-  });
-}
+  # Transfer services and cancel old package.
 
-=item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
+  $error = $self->transfer($cust_pkg);
+  if ($error and $error == 0) {
+    # $old_pkg->transfer failed.
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
 
-Sets customer package details for this package (see L<FS::cust_pkg_detail>).
+  if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
+    warn "trying transfer again with change_svcpart option\n" if $DEBUG;
+    $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
+    if ($error and $error == 0) {
+      # $old_pkg->transfer failed.
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
 
-DETAILTYPE can be set to "I" for invoice details or "C" for comments.
+  if ($error > 0) {
+    # Transfers were successful, but we still had services left on the old
+    # package.  We can't change the package under this circumstances, so abort.
+    $dbh->rollback if $oldAutoCommit;
+    return "Unable to transfer all services from package ". $self->pkgnum;
+  }
 
-If there is an error, returns the error, otherwise returns false.
+  #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);
 
-=cut
-
-sub set_cust_pkg_detail {
-  my( $self, $detailtype, @details ) = @_;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
+    if ($error) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error setting usage values: $error";
+    }
+  } else {
+    # if NOT changing pkgpart, transfer any usage pools over
+    foreach my $usage ($self->cust_pkg_usage) {
+      $usage->set('pkgnum', $cust_pkg->pkgnum);
+      $error = $usage->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error transferring usage pools: $error";
+      }
+    }
+  }
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
+  # transfer discounts, if we're not changing pkgpart
+  if ( $same_pkgpart ) {
+    foreach my $old_discount ($self->cust_pkg_discount_active) {
+      # don't remove the old discount, we may still need to bill that package.
+      my $new_discount = new FS::cust_pkg_discount {
+        'pkgnum'      => $cust_pkg->pkgnum,
+        'discountnum' => $old_discount->discountnum,
+        'months_used' => $old_discount->months_used,
+      };
+      $error = $new_discount->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error transferring discounts: $error";
+      }
+    }
+  }
 
-  foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
-    my $error = $current->delete;
+  # Order any supplemental packages.
+  my $part_pkg = $cust_pkg->part_pkg;
+  my @old_supp_pkgs = $self->supplemental_pkgs;
+  my @new_supp_pkgs;
+  foreach my $link ($part_pkg->supp_part_pkg_link) {
+    my $old;
+    foreach (@old_supp_pkgs) {
+      if ($_->pkgpart == $link->dst_pkgpart) {
+        $old = $_;
+        $_->pkgpart(0); # so that it can't match more than once
+      }
+      last if $old;
+    }
+    # false laziness with FS::cust_main::Packages::order_pkg
+    my $new = FS::cust_pkg->new({
+        pkgpart       => $link->dst_pkgpart,
+        pkglinknum    => $link->pkglinknum,
+        custnum       => $custnum,
+        main_pkgnum   => $cust_pkg->pkgnum,
+        locationnum   => $cust_pkg->locationnum,
+        start_date    => $cust_pkg->start_date,
+        order_date    => $cust_pkg->order_date,
+        expire        => $cust_pkg->expire,
+        adjourn       => $cust_pkg->adjourn,
+        contract_end  => $cust_pkg->contract_end,
+        refnum        => $cust_pkg->refnum,
+        discountnum   => $cust_pkg->discountnum,
+        waive_setup   => $cust_pkg->waive_setup,
+    });
+    if ( $old and $opt->{'keep_dates'} ) {
+      foreach (qw(setup bill last_bill)) {
+        $new->set($_, $old->get($_));
+      }
+    }
+    $error = $new->insert( allow_pkgpart => $same_pkgpart );
+    # transfer services
+    if ( $old ) {
+      $error ||= $old->transfer($new);
+    }
+    if ( $error and $error > 0 ) {
+      # no reason why this should ever fail, but still...
+      $error = "Unable to transfer all services from supplemental package ".
+        $old->pkgnum;
+    }
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error removing old detail: $error";
+      return $error;
     }
+    push @new_supp_pkgs, $new;
   }
 
-  foreach my $detail ( @details ) {
-    my $cust_pkg_detail = new FS::cust_pkg_detail {
-      'pkgnum'     => $self->pkgnum,
-      'detailtype' => $detailtype,
-      'detail'     => $detail,
-    };
-    my $error = $cust_pkg_detail->insert;
+  #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
+  #remaining time.
+  #Don't allow billing the package (preceding period packages and/or 
+  #outstanding usage) if we are keeping dates (i.e. location changing), 
+  #because the new package will be billed for the same date range.
+  #Supplemental packages are also canceled here.
+  $error = $self->cancel(
+    quiet          => 1, 
+    unused_credit  => $unused_credit,
+    nobill         => $keep_dates,
+    change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
+  );
+  if ($error) {
+    $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, @new_supp_pkgs ]
+    );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error adding new detail: $error";
+      return $error;
     }
-
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+
+  $cust_pkg;
 
 }
 
-=item cust_event
+=item set_quantity QUANTITY
 
-Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
+Change the package's quantity field.  This is the one package property
+that can safely be changed without canceling and reordering the package
+(because it doesn't affect tax eligibility).  Returns an error or an 
+empty string.
 
 =cut
 
-#false laziness w/cust_bill.pm
-sub cust_event {
+sub set_quantity {
   my $self = shift;
-  qsearch({
-    'table'     => 'cust_event',
-    'addl_from' => 'JOIN part_event USING ( eventpart )',
-    'hashref'   => { 'tablenum' => $self->pkgnum },
-    'extra_sql' => " AND eventtable = 'cust_pkg' ",
-  });
+  $self = $self->replace_old; # just to make sure
+  my $qty = shift;
+  ($qty =~ /^\d+$/ and $qty > 0) or return "bad package quantity $qty";
+  $self->set('quantity' => $qty);
+  $self->replace;
 }
 
-=item num_cust_event
+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
 
-Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
 
-=cut
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
 
-#false laziness w/cust_bill.pm
-sub num_cust_event {
-  my $self = shift;
-  my $sql =
-    "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
-    "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
-  my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
-  $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
-  $sth->fetchrow_arrayref->[0];
+  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 cust_svc [ SVCPART ]
+=item last_bill
 
-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.
+Returns the last bill date, or if there is no last bill date, the setup date.
+Useful for billing metered services.
 
 =cut
 
-sub cust_svc {
+sub last_bill {
   my $self = shift;
+  return $self->setfield('last_bill', $_[0]) if @_;
+  return $self->getfield('last_bill') if $self->getfield('last_bill');
+  my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
+                                                  'edate'  => $self->bill,  } );
+  $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
+}
 
-  if ( @_ ) {
-    return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
-                                  'svcpart' => shift,          } );
-  }
+=item last_cust_pkg_reason ACTION
 
-  #if ( $self->{'_svcnum'} ) {
-  #  values %{ $self->{'_svcnum'}->cache };
-  #} else {
-    $self->_sort_cust_svc(
-      [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
-    );
-  #}
+Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
+Returns false if there is no reason or the package is not currenly ACTION'd
+ACTION is one of adjourn, susp, cancel, or expire.
+
+=cut
 
+sub last_cust_pkg_reason {
+  my ( $self, $action ) = ( shift, shift );
+  my $date = $self->get($action);
+  qsearchs( {
+              'table' => 'cust_pkg_reason',
+              'hashref' => { 'pkgnum' => $self->pkgnum,
+                             'action' => substr(uc($action), 0, 1),
+                             'date'   => $date,
+                           },
+              'order_by' => 'ORDER BY num DESC LIMIT 1',
+           } );
 }
 
-=item overlimit [ SVCPART ]
+=item last_reason ACTION
 
-Returns the services for this package which have exceeded their
-usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
-is specified, return only the matching services.
+Returns the most recent ACTION FS::reason associated with the package.
+Returns false if there is no reason or the package is not currenly ACTION'd
+ACTION is one of adjourn, susp, cancel, or expire.
 
 =cut
 
-sub overlimit {
-  my $self = shift;
-  grep { $_->overlimit } $self->cust_svc;
+sub last_reason {
+  my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
+  $cust_pkg_reason->reason
+    if $cust_pkg_reason;
 }
 
-=item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] 
+=item part_pkg
 
-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>).
+Returns the definition for this billing item, as an FS::part_pkg object (see
+L<FS::part_pkg>).
 
 =cut
 
-sub h_cust_svc {
+sub part_pkg {
   my $self = shift;
-
-  $self->_sort_cust_svc(
-    [ qsearch( 'h_cust_svc',
-               { 'pkgnum' => $self->pkgnum, },
-               FS::h_cust_svc->sql_h_search(@_),
-             )
-    ]
-  );
+  return $self->{'_pkgpart'} if $self->{'_pkgpart'};
+  cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
+  qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
 }
 
-sub _sort_cust_svc {
-  my( $self, $arrayref ) = @_;
+=item old_cust_pkg
 
-  map  { $_->[0] }
-  sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
-  map {
-        my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
-                                             'svcpart' => $_->svcpart     } );
-        [ $_,
-          $pkg_svc ? $pkg_svc->primary_svc : '',
-          $pkg_svc ? $pkg_svc->quantity : 0,
-        ];
-      }
-  @$arrayref;
+Returns the cancelled package this package was changed from, if any.
 
+=cut
+
+sub old_cust_pkg {
+  my $self = shift;
+  return '' unless $self->change_pkgnum;
+  qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
 }
 
-=item num_cust_svc [ SVCPART ]
+=item change_cust_main
 
-Returns the number of provisioned services for this package.  If a svcpart is
-specified, counts only the matching services.
+Returns the customter this package was detached to, if any.
 
 =cut
 
-sub num_cust_svc {
+sub change_cust_main {
   my $self = shift;
-  my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
-  $sql .= ' AND svcpart = ?' if @_;
-  my $sth = dbh->prepare($sql) or die dbh->errstr;
-  $sth->execute($self->pkgnum, @_) or die $sth->errstr;
-  $sth->fetchrow_arrayref->[0];
+  return '' unless $self->change_custnum;
+  qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
 }
 
-=item available_part_svc 
+=item calc_setup
 
-Returns a list of FS::part_svc objects representing services included in this
-package but not yet provisioned.  Each FS::part_svc object also has an extra
-field, I<num_avail>, which specifies the number of available services.
+Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
+item.
 
 =cut
 
-sub available_part_svc {
+sub calc_setup {
   my $self = shift;
-  grep { $_->num_avail > 0 }
-    map {
-          my $part_svc = $_->part_svc;
-          $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
-            $_->quantity - $self->num_cust_svc($_->svcpart);
-          $part_svc;
-        }
-      $self->part_pkg->pkg_svc;
+  $self->part_pkg->calc_setup($self, @_);
 }
 
-=item part_svc
-
-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
-following extra fields:
-
-=over 4
+=item calc_recur
 
-=item num_cust_svc  (count)
+Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
+item.
 
-=item num_avail     (quantity - count)
+=cut
 
-=item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
+sub calc_recur {
+  my $self = shift;
+  $self->part_pkg->calc_recur($self, @_);
+}
 
-svcnum
-label -> ($cust_svc->label)[1]
+=item base_recur
 
-=back
+Calls the I<base_recur> of the FS::part_pkg object associated with this billing
+item.
 
 =cut
 
-sub part_svc {
+sub base_recur {
   my $self = shift;
+  $self->part_pkg->base_recur($self, @_);
+}
 
-  #XXX some sort of sort order besides numeric by svcpart...
-  my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
-    my $pkg_svc = $_;
-    my $part_svc = $pkg_svc->part_svc;
-    my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
-    $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;
-  } $self->part_pkg->pkg_svc;
+=item calc_remain
 
-  #extras
-  push @part_svc, map {
-    my $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;
-  } $self->extra_part_svc;
+Calls the I<calc_remain> of the FS::part_pkg object associated with this
+billing item.
 
-  @part_svc;
+=cut
 
+sub calc_remain {
+  my $self = shift;
+  $self->part_pkg->calc_remain($self, @_);
 }
 
-=item extra_part_svc
+=item calc_cancel
 
-Returns a list of FS::part_svc objects corresponding to services in this
-package which are still provisioned but not (any longer) available in the
-package definition.
+Calls the I<calc_cancel> of the FS::part_pkg object associated with this
+billing item.
 
 =cut
 
-sub extra_part_svc {
+sub calc_cancel {
   my $self = shift;
-
-  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 = $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
-                             )",
-  } );
+  $self->part_pkg->calc_cancel($self, @_);
 }
 
-=item status
+=item cust_bill_pkg
 
-Returns a short status string for this package, currently:
-
-=over 4
-
-=item not yet billed
+Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
 
-=item one-time charge
+=cut
 
-=item active
+sub cust_bill_pkg {
+  my $self = shift;
+  qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
+}
 
-=item suspended
+=item cust_pkg_detail [ DETAILTYPE ]
 
-=item cancelled
+Returns any customer package details for this package (see
+L<FS::cust_pkg_detail>).
 
-=back
+DETAILTYPE can be set to "I" for invoice details or "C" for comments.
 
 =cut
 
-sub status {
+sub cust_pkg_detail {
   my $self = shift;
-
-  my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
-
-  return 'cancelled' if $self->get('cancel');
-  return 'suspended' if $self->susp;
-  return 'not yet billed' unless $self->setup;
-  return 'one-time charge' if $freq =~ /^(0|$)/;
-  return 'active';
+  my %hash = ( 'pkgnum' => $self->pkgnum );
+  $hash{detailtype} = shift if @_;
+  qsearch({
+    'table'    => 'cust_pkg_detail',
+    'hashref'  => \%hash,
+    'order_by' => 'ORDER BY weight, pkgdetailnum',
+  });
 }
 
-=item statuses
+=item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
 
-Class method that returns the list of possible status strings for packages
-(see L<the status method|/status>).  For example:
+Sets customer package details for this package (see L<FS::cust_pkg_detail>).
 
-  @statuses = FS::cust_pkg->statuses();
+DETAILTYPE can be set to "I" for invoice details or "C" for comments.
+
+If there is an error, returns the error, otherwise returns false.
 
 =cut
 
-tie my %statuscolor, 'Tie::IxHash', 
-  'not yet billed'  => '000000',
-  'one-time charge' => '000000',
-  'active'          => '00CC00',
-  'suspended'       => 'FF9900',
-  'cancelled'       => 'FF0000',
-;
+sub set_cust_pkg_detail {
+  my( $self, $detailtype, @details ) = @_;
 
-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
-    keys %statuscolor;
-}
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
 
-=item statuscolor
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
 
-Returns a hex triplet color string for this package's status.
+  foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
+    my $error = $current->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error removing old detail: $error";
+    }
+  }
 
-=cut
+  foreach my $detail ( @details ) {
+    my $cust_pkg_detail = new FS::cust_pkg_detail {
+      'pkgnum'     => $self->pkgnum,
+      'detailtype' => $detailtype,
+      'detail'     => $detail,
+    };
+    my $error = $cust_pkg_detail->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error adding new detail: $error";
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
 
-sub statuscolor {
-  my $self = shift;
-  $statuscolor{$self->status};
 }
 
-=item labels
+=item cust_event
 
-Returns a list of lists, calling the label method for all services
-(see L<FS::cust_svc>) of this billing item.
+Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
 
 =cut
 
-sub labels {
+#false laziness w/cust_bill.pm
+sub cust_event {
   my $self = shift;
-  map { [ $_->label ] } $self->cust_svc;
+  qsearch({
+    'table'     => 'cust_event',
+    'addl_from' => 'JOIN part_event USING ( eventpart )',
+    'hashref'   => { 'tablenum' => $self->pkgnum },
+    'extra_sql' => " AND eventtable = 'cust_pkg' ",
+  });
 }
 
-=item h_labels END_TIMESTAMP [ START_TIMESTAMP ] 
-
-Like the labels method, but returns historical information on services that
-were active as of END_TIMESTAMP and (optionally) not cancelled before
-START_TIMESTAMP.
+=item num_cust_event
 
-Returns a list of lists, calling the label method for all (historical) services
-(see L<FS::h_cust_svc>) of this billing item.
+Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
 
 =cut
 
-sub h_labels {
+#false laziness w/cust_bill.pm
+sub num_cust_event {
   my $self = shift;
-  map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
+  my $sql =
+    "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
+    "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
+  my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
+  $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
+  $sth->fetchrow_arrayref->[0];
 }
 
-=item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
+=item cust_svc [ SVCPART ] (old, deprecated usage)
 
-Like h_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.
+=item cust_svc [ OPTION => VALUE ... ] (current usage)
+
+Returns the services for this package, as FS::cust_svc objects (see
+L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
+spcififed, returns only the matching services.
 
 =cut
 
-sub h_labels_short {
+sub cust_svc {
   my $self = shift;
 
-  my $conf = new FS::Conf;
-  my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
+  return () unless $self->num_cust_svc(@_);
 
-  my %labels;
-  #tie %labels, 'Tie::IxHash';
-  push @{ $labels{$_->[0]} }, $_->[1]
-    foreach $self->h_labels(@_);
-  my @labels;
-  foreach my $label ( keys %labels ) {
-    my %seen = ();
-    my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
-    my $num = scalar(@values);
-    if ( $num > $max_same_services ) {
-      push @labels, "$label ($num)";
-    } else {
-      push @labels, map { "$label: $_" } @values;
-    }
+  my %opt = ();
+  if ( @_ && $_[0] =~ /^\d+/ ) {
+    $opt{svcpart} = shift;
+  } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
+    %opt = %{ $_[0] };
+  } elsif ( @_ ) {
+    %opt = @_;
   }
 
- @labels;
+  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'} );
+  }
+
+  cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
+
+  #if ( $self->{'_svcnum'} ) {
+  #  values %{ $self->{'_svcnum'}->cache };
+  #} else {
+    $self->_sort_cust_svc( [ qsearch(\%search) ] );
+  #}
 
 }
 
-=item cust_main
+=item overlimit [ SVCPART ]
 
-Returns the parent customer object (see L<FS::cust_main>).
+Returns the services for this package which have exceeded their
+usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
+is specified, return only the matching services.
 
 =cut
 
-sub cust_main {
+sub overlimit {
   my $self = shift;
-  qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
+  return () unless $self->num_cust_svc(@_);
+  grep { $_->overlimit } $self->cust_svc(@_);
 }
 
-=item cust_location
+=item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
 
-Returns the location object, if any (see L<FS::cust_location>).
+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>).  If MODE is 'I' (for 'invoice'), services with the 
+I<pkg_svc.hidden> flag will be omitted.
 
 =cut
 
-sub cust_location {
+sub h_cust_svc {
   my $self = shift;
-  return '' unless $self->locationnum;
-  qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
+  warn "$me _h_cust_svc called on $self\n"
+    if $DEBUG;
+
+  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(@_),  
+    ) ]
+  );
+  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;
+  }
 }
 
-=item cust_location_or_main
+sub _sort_cust_svc {
+  my( $self, $arrayref ) = @_;
 
-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>).
+  my $sort =
+    sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
 
-=cut
+  map  { $_->[0] }
+  sort $sort
+  map {
+        my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
+                                             'svcpart' => $_->svcpart     } );
+        [ $_,
+          $pkg_svc ? $pkg_svc->primary_svc : '',
+          $pkg_svc ? $pkg_svc->quantity : 0,
+        ];
+      }
+  @$arrayref;
 
-sub cust_location_or_main {
-  my $self = shift;
-  $self->cust_location || $self->cust_main;
 }
 
-=item seconds_since TIMESTAMP
+=item num_cust_svc [ SVCPART ] (old, deprecated usage)
 
-Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
-package have been online since TIMESTAMP, according to the session monitor.
+=item num_cust_svc [ OPTION => VALUE ... ] (current usage)
 
-TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
-L<Time::Local> and L<Date::Parse> for conversion functions.
+Returns the number of services for this package.  Available options are svcpart
+and svcdb.  If either is spcififed, returns only the matching services.
 
 =cut
 
-sub seconds_since {
-  my($self, $since) = @_;
-  my $seconds = 0;
+sub num_cust_svc {
+  my $self = shift;
 
-  foreach my $cust_svc (
-    grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
-  ) {
-    $seconds += $cust_svc->seconds_since($since);
+  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 %opt = ();
+  if ( @_ && $_[0] =~ /^\d+/ ) {
+    $opt{svcpart} = shift;
+  } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
+    %opt = %{ $_[0] };
+  } elsif ( @_ ) {
+    %opt = @_;
   }
 
-  $seconds;
-
-}
+  my $select = 'SELECT COUNT(*) FROM cust_svc ';
+  my $where = ' WHERE pkgnum = ? ';
+  my @param = ($self->pkgnum);
 
-=item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
+  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'};
+  }
 
-Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
-package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
-(exclusive).
+  my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
+  $sth->execute(@param) or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
 
-TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
-L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
-functions.
+=item available_part_svc 
 
+Returns a list of FS::part_svc objects representing services included in this
+package but not yet provisioned.  Each FS::part_svc object also has an extra
+field, I<num_avail>, which specifies the number of available services.
 
 =cut
 
-sub seconds_since_sqlradacct {
-  my($self, $start, $end) = @_;
+sub available_part_svc {
+  my $self = shift;
 
-  my $seconds = 0;
+  my $pkg_quantity = $self->quantity || 1;
 
-  foreach my $cust_svc (
-    grep {
-      my $part_svc = $_->part_svc;
-      $part_svc->svcdb eq 'svc_acct'
-        && scalar($part_svc->part_export('sqlradius'));
-    } $self->cust_svc
-  ) {
-    $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
-  }
+  grep { $_->num_avail > 0 }
+    map {
+          my $part_svc = $_->part_svc;
+          $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
+            $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
 
-  $seconds;
+         # 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 attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
+=item part_svc [ OPTION => VALUE ... ]
 
-Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
-in this package for sessions ending between TIMESTAMP_START (inclusive) and
-TIMESTAMP_END
-(exclusive).
+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
+following extra fields:
+
+=over 4
+
+=item num_cust_svc  (count)
+
+=item num_avail     (quantity - count)
+
+=item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
+
+=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 = @_;
+
+  my $pkg_quantity = $self->quantity || 1;
+
+  #XXX some sort of sort order besides numeric by svcpart...
+  my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
+    my $pkg_svc = $_;
+    my $part_svc = $pkg_svc->part_svc;
+    my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
+    $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
+    $part_svc->{'Hash'}{'num_avail'}    =
+      max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
+    $part_svc->{'Hash'}{'cust_pkg_svc'} =
+        $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;
+
+  #extras
+  push @part_svc, map {
+    my $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'} =
+      $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
+    $part_svc;
+  } $self->extra_part_svc;
+
+  @part_svc;
+
+}
+
+=item extra_part_svc
+
+Returns a list of FS::part_svc objects corresponding to services in this
+package which are still provisioned but not (any longer) available in the
+package definition.
+
+=cut
+
+sub extra_part_svc {
+  my $self = shift;
+
+  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... (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 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->pkgnum=>'int'] ],
+  } );
+}
+
+=item status
+
+Returns a short status string for this package, currently:
+
+=over 4
+
+=item not yet billed
+
+=item one-time charge
+
+=item active
+
+=item suspended
+
+=item cancelled
+
+=back
+
+=cut
+
+sub status {
+  my $self = shift;
+
+  my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
+
+  return 'cancelled' if $self->get('cancel');
+  return 'suspended' if $self->susp;
+  return 'not yet billed' unless $self->setup;
+  return 'one-time charge' if $freq =~ /^(0|$)/;
+  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
+(see L<the status method|/status>).  For example:
+
+  @statuses = FS::cust_pkg->statuses();
+
+=cut
+
+tie my %statuscolor, 'Tie::IxHash', 
+  'not yet billed'  => '009999', #teal? cyan?
+  'one-time charge' => '000000',
+  'active'          => '00CC00',
+  'suspended'       => 'FF9900',
+  'cancelled'       => 'FF0000',
+;
+
+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
+    keys %statuscolor;
+}
+
+=item statuscolor
+
+Returns a hex triplet color string for this package's status.
+
+=cut
+
+sub statuscolor {
+  my $self = shift;
+  $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 pkg_locale
+
+Returns a customer-localized label for this package.
+
+=cut
+
+sub pkg_locale {
+  my $self = shift;
+  $self->part_pkg->pkg_locale( $self->cust_main->locale );
+}
+
+=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
+(see L<FS::cust_svc>) of this billing item.
+
+=cut
+
+sub labels {
+  my $self = shift;
+  map { [ $_->label ] } $self->cust_svc;
+}
+
+=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.  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.
+
+=cut
+
+sub h_labels {
+  my $self = shift;
+  warn "$me _h_labels called on $self\n"
+    if $DEBUG;
+  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
+(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 h_labels_short {
+  shift->_labels_short( 'h_labels', @_ );
+}
+
+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->$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 || $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;
+      }
+    }
+  }
+
+ @labels;
+
+}
+
+=item cust_main
+
+Returns the parent customer object (see L<FS::cust_main>).
+
+=cut
+
+sub cust_main {
+  my $self = shift;
+  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
+
+Returns the location object, if any (see L<FS::cust_location>).
+
+=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
+
+#end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
+
+=item tax_locationnum
+
+Returns the foreign key to a L<FS::cust_location> object for calculating  
+tax on this package, as determined by the C<tax-pkg_address> and 
+C<tax-ship_address> configuration flags.
+
+=cut
+
+sub tax_locationnum {
+  my $self = shift;
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('tax-pkg_address') ) {
+    return $self->locationnum;
+  }
+  elsif ( $conf->exists('tax-ship_address') ) {
+    return $self->cust_main->ship_locationnum;
+  }
+  else {
+    return $self->cust_main->bill_locationnum;
+  }
+}
+
+=item tax_location
+
+Returns the L<FS::cust_location> object for tax_locationnum.
+
+=cut
+
+sub tax_location {
+  my $self = shift;
+  FS::cust_location->by_key( $self->tax_locationnum )
+}
+
+=item seconds_since TIMESTAMP
+
+Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
+package have been online since TIMESTAMP, according to the session monitor.
+
+TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=cut
+
+sub seconds_since {
+  my($self, $since) = @_;
+  my $seconds = 0;
+
+  foreach my $cust_svc (
+    grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
+  ) {
+    $seconds += $cust_svc->seconds_since($since);
+  }
+
+  $seconds;
+
+}
+
+=item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
+
+Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
+package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
+(exclusive).
+
+TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
+L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
+functions.
+
+
+=cut
+
+sub seconds_since_sqlradacct {
+  my($self, $start, $end) = @_;
+
+  my $seconds = 0;
+
+  foreach my $cust_svc (
+    grep {
+      my $part_svc = $_->part_svc;
+      $part_svc->svcdb eq 'svc_acct'
+        && scalar($part_svc->part_export_usage);
+    } $self->cust_svc
+  ) {
+    $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
+  }
+
+  $seconds;
+
+}
+
+=item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
+
+Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
+in this package for sessions ending between TIMESTAMP_START (inclusive) and
+TIMESTAMP_END
+(exclusive).
+
+TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
+L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
+functions.
+
+=cut
+
+sub attribute_since_sqlradacct {
+  my($self, $start, $end, $attrib) = @_;
+
+  my $sum = 0;
+
+  foreach my $cust_svc (
+    grep {
+      my $part_svc = $_->part_svc;
+      $part_svc->svcdb eq 'svc_acct'
+        && scalar($part_svc->part_export_usage);
+    } $self->cust_svc
+  ) {
+    $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
+  }
+
+  $sum;
+
+}
+
+=item quantity
+
+=cut
+
+sub quantity {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('quantity', $value);
+  }
+  $self->getfield('quantity') || 1;
+}
+
+=item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
+
+Transfers as many services as possible from this package to another package.
+
+The destination package can be specified by pkgnum by passing an FS::cust_pkg
+object.  The destination package must already exist.
+
+Services are moved only if the destination allows services with the correct
+I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
+this option with caution!  No provision is made for export differences
+between the old and new service definitions.  Probably only should be used
+when your exports for all service definitions of a given svcdb are identical.
+(attempt a transfer without it first, to move all possible svcpart-matching
+services)
+
+Any services that can't be moved remain in the original package.
+
+Returns an error, if there is one; otherwise, returns the number of services 
+that couldn't be moved.
+
+=cut
+
+sub transfer {
+  my ($self, $dest_pkgnum, %opt) = @_;
 
-TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
-L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
-functions.
+  my $remaining = 0;
+  my $dest;
+  my %target;
+
+  if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
+    $dest = $dest_pkgnum;
+    $dest_pkgnum = $dest->pkgnum;
+  } else {
+    $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
+  }
+
+  return ('Package does not exist: '.$dest_pkgnum) unless $dest;
+
+  foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
+    $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
+  }
+
+  foreach my $cust_svc ($dest->cust_svc) {
+    $target{$cust_svc->svcpart}--;
+  }
+
+  my %svcpart2svcparts = ();
+  if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
+    warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
+    foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
+      next if exists $svcpart2svcparts{$svcpart};
+      my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
+      $svcpart2svcparts{$svcpart} = [
+        map  { $_->[0] }
+        sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
+        map {
+              my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
+                                                   'svcpart' => $_          } );
+              [ $_,
+                $pkg_svc ? $pkg_svc->primary_svc : '',
+                $pkg_svc ? $pkg_svc->quantity : 0,
+              ];
+            }
+
+        grep { $_ != $svcpart }
+        map  { $_->svcpart }
+        qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
+      ];
+      warn "alternates for svcpart $svcpart: ".
+           join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
+        if $DEBUG;
+    }
+  }
+
+  foreach my $cust_svc ($self->cust_svc) {
+    if($target{$cust_svc->svcpart} > 0
+       or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
+      $target{$cust_svc->svcpart}--;
+      my $new = new FS::cust_svc { $cust_svc->hash };
+      $new->pkgnum($dest_pkgnum);
+      my $error = $new->replace($cust_svc);
+      return $error if $error;
+    } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
+      if ( $DEBUG ) {
+        warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
+        warn "alternates to consider: ".
+             join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
+      }
+      my @alternate = grep {
+                             warn "considering alternate svcpart $_: ".
+                                  "$target{$_} available in new package\n"
+                               if $DEBUG;
+                             $target{$_} > 0;
+                           } @{$svcpart2svcparts{$cust_svc->svcpart}};
+      if ( @alternate ) {
+        warn "alternate(s) found\n" if $DEBUG;
+        my $change_svcpart = $alternate[0];
+        $target{$change_svcpart}--;
+        my $new = new FS::cust_svc { $cust_svc->hash };
+        $new->svcpart($change_svcpart);
+        $new->pkgnum($dest_pkgnum);
+        my $error = $new->replace($cust_svc);
+        return $error if $error;
+      } else {
+        $remaining++;
+      }
+    } else {
+      $remaining++
+    }
+  }
+  return $remaining;
+}
+
+=item reexport
+
+This method is deprecated.  See the I<depend_jobnum> option to the insert and
+order_pkgs methods in FS::cust_main for a better way to defer provisioning.
 
 =cut
 
-sub attribute_since_sqlradacct {
-  my($self, $start, $end, $attrib) = @_;
+sub reexport {
+  my $self = shift;
 
-  my $sum = 0;
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
 
-  foreach my $cust_svc (
-    grep {
-      my $part_svc = $_->part_svc;
-      $part_svc->svcdb eq 'svc_acct'
-        && scalar($part_svc->part_export('sqlradius'));
-    } $self->cust_svc
-  ) {
-    $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  foreach my $cust_svc ( $self->cust_svc ) {
+    #false laziness w/svc_Common::insert
+    my $svc_x = $cust_svc->svc_x;
+    foreach my $part_export ( $cust_svc->part_svc->part_export ) {
+      my $error = $part_export->export_insert($svc_x);
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
   }
 
-  $sum;
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=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 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
+    #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,
+  };
+
+  $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) = @_;
+
+  #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");
+  }
+}
+
+=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) = @_;
+
+  #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");
+  }
 }
 
-=item quantity
+=item cust_pkg_discount
 
 =cut
 
-sub quantity {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('quantity', $value);
-  }
-  $self->getfield('quantity') || 1;
+sub cust_pkg_discount {
+  my $self = shift;
+  qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
 }
 
-=item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
-
-Transfers as many services as possible from this package to another package.
+=item cust_pkg_discount_active
 
-The destination package can be specified by pkgnum by passing an FS::cust_pkg
-object.  The destination package must already exist.
+=cut
 
-Services are moved only if the destination allows services with the correct
-I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
-this option with caution!  No provision is made for export differences
-between the old and new service definitions.  Probably only should be used
-when your exports for all service definitions of a given svcdb are identical.
-(attempt a transfer without it first, to move all possible svcpart-matching
-services)
+sub cust_pkg_discount_active {
+  my $self = shift;
+  grep { $_->status eq 'active' } $self->cust_pkg_discount;
+}
 
-Any services that can't be moved remain in the original package.
+=item cust_pkg_usage
 
-Returns an error, if there is one; otherwise, returns the number of services 
-that couldn't be moved.
+Returns a list of all voice usage counters attached to this package.
 
 =cut
 
-sub transfer {
-  my ($self, $dest_pkgnum, %opt) = @_;
+sub cust_pkg_usage {
+  my $self = shift;
+  qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
+}
 
-  my $remaining = 0;
-  my $dest;
-  my %target;
+=item apply_usage OPTIONS
 
-  if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
-    $dest = $dest_pkgnum;
-    $dest_pkgnum = $dest->pkgnum;
-  } else {
-    $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
-  }
+Takes the following options:
+- cdr: a call detail record (L<FS::cdr>)
+- rate_detail: the rate determined for this call (L<FS::rate_detail>)
+- minutes: the maximum number of minutes to be charged
 
-  return ('Package does not exist: '.$dest_pkgnum) unless $dest;
+Finds available usage minutes for a call of this class, and subtracts
+up to that many minutes from the usage pool.  If the usage pool is empty,
+and the C<cdr-minutes_priority> global config option is set, minutes may
+be taken from other calls as well.  Either way, an allocation record will
+be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
+number of minutes of usage applied to the call.
 
-  foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
-    $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
-  }
+=cut
 
-  foreach my $cust_svc ($dest->cust_svc) {
-    $target{$cust_svc->svcpart}--;
-  }
+sub apply_usage {
+  my ($self, %opt) = @_;
+  my $cdr = $opt{cdr};
+  my $rate_detail = $opt{rate_detail};
+  my $minutes = $opt{minutes};
+  my $classnum = $rate_detail->classnum;
+  my $pkgnum = $self->pkgnum;
+  my $custnum = $self->custnum;
 
-  my %svcpart2svcparts = ();
-  if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
-    warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
-    foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
-      next if exists $svcpart2svcparts{$svcpart};
-      my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
-      $svcpart2svcparts{$svcpart} = [
-        map  { $_->[0] }
-        sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
-        map {
-              my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
-                                                   'svcpart' => $_          } );
-              [ $_,
-                $pkg_svc ? $pkg_svc->primary_svc : '',
-                $pkg_svc ? $pkg_svc->quantity : 0,
-              ];
-            }
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE'; 
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE'; 
+  local $SIG{PIPE} = 'IGNORE'; 
 
-        grep { $_ != $svcpart }
-        map  { $_->svcpart }
-        qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
-      ];
-      warn "alternates for svcpart $svcpart: ".
-           join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
-        if $DEBUG;
-    }
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  my $order = FS::Conf->new->config('cdr-minutes_priority');
+
+  my $is_classnum;
+  if ( $classnum ) {
+    $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
+  } else {
+    $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
   }
+  my @usage_recs = qsearch({
+      'table'     => 'cust_pkg_usage',
+      'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
+                     ' JOIN cust_pkg             USING (pkgnum)'.
+                     ' JOIN part_pkg_usage_class USING (pkgusagepart)',
+      'select'    => 'cust_pkg_usage.*',
+      'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
+                     " ( cust_pkg.custnum = $custnum AND ".
+                     " part_pkg_usage.shared IS NOT NULL ) ) AND ".
+                     $is_classnum . ' AND '.
+                     " cust_pkg_usage.minutes > 0",
+      'order_by'  => " ORDER BY priority ASC",
+  });
 
-  foreach my $cust_svc ($self->cust_svc) {
-    if($target{$cust_svc->svcpart} > 0) {
-      $target{$cust_svc->svcpart}--;
-      my $new = new FS::cust_svc { $cust_svc->hash };
-      $new->pkgnum($dest_pkgnum);
-      my $error = $new->replace($cust_svc);
-      return $error if $error;
-    } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
-      if ( $DEBUG ) {
-        warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
-        warn "alternates to consider: ".
-             join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
+  my $orig_minutes = $minutes;
+  my $error;
+  while (!$error and $minutes > 0 and @usage_recs) {
+    my $cust_pkg_usage = shift @usage_recs;
+    $cust_pkg_usage->select_for_update;
+    my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
+        pkgusagenum => $cust_pkg_usage->pkgusagenum,
+        acctid      => $cdr->acctid,
+        minutes     => min($cust_pkg_usage->minutes, $minutes),
+    });
+    $cust_pkg_usage->set('minutes',
+      sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes)
+    );
+    $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
+    $minutes -= $cdr_cust_pkg_usage->minutes;
+  }
+  if ( $order and $minutes > 0 and !$error ) {
+    # then try to steal minutes from another call
+    my %search = (
+        'table'     => 'cdr_cust_pkg_usage',
+        'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
+                       ' JOIN part_pkg_usage        USING (pkgusagepart)'.
+                       ' JOIN cust_pkg              USING (pkgnum)'.
+                       ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
+                       ' JOIN cdr                   USING (acctid)',
+        'select'    => 'cdr_cust_pkg_usage.*',
+        'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
+                       " ( cust_pkg.pkgnum = $pkgnum OR ".
+                       " ( cust_pkg.custnum = $custnum AND ".
+                       " part_pkg_usage.shared IS NOT NULL ) ) AND ".
+                       " part_pkg_usage_class.classnum = $classnum",
+        'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
+    );
+    if ( $order eq 'time' ) {
+      # find CDRs that are using minutes, but have a later startdate
+      # than this call
+      my $startdate = $cdr->startdate;
+      if ($startdate !~ /^\d+$/) {
+        die "bad cdr startdate '$startdate'";
       }
-      my @alternate = grep {
-                             warn "considering alternate svcpart $_: ".
-                                  "$target{$_} available in new package\n"
-                               if $DEBUG;
-                             $target{$_} > 0;
-                           } @{$svcpart2svcparts{$cust_svc->svcpart}};
-      if ( @alternate ) {
-        warn "alternate(s) found\n" if $DEBUG;
-        my $change_svcpart = $alternate[0];
-        $target{$change_svcpart}--;
-        my $new = new FS::cust_svc { $cust_svc->hash };
-        $new->svcpart($change_svcpart);
-        $new->pkgnum($dest_pkgnum);
-        my $error = $new->replace($cust_svc);
-        return $error if $error;
+      $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
+      # minimize needless reshuffling
+      $search{'order_by'} .= ', cdr.startdate DESC';
+    } else {
+      # XXX may not work correctly with rate_time schedules.  Could 
+      # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
+      # think...
+      $search{'addl_from'} .=
+        ' JOIN rate_detail'.
+        ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
+      if ( $order eq 'rate_high' ) {
+        $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
+                                $rate_detail->min_charge;
+        $search{'order_by'} .= ', rate_detail.min_charge ASC';
+      } elsif ( $order eq 'rate_low' ) {
+        $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
+                                $rate_detail->min_charge;
+        $search{'order_by'} .= ', rate_detail.min_charge DESC';
       } else {
-        $remaining++;
+        #  this should really never happen
+        die "invalid cdr-minutes_priority value '$order'\n";
       }
-    } else {
-      $remaining++
     }
+    my @cdr_usage_recs = qsearch(\%search);
+    my %reproc_cdrs;
+    while (!$error and @cdr_usage_recs and $minutes > 0) {
+      my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
+      my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
+      my $old_cdr = $cdr_cust_pkg_usage->cdr;
+      $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
+      $cdr_cust_pkg_usage->select_for_update;
+      $old_cdr->select_for_update;
+      $cust_pkg_usage->select_for_update;
+      # in case someone else stole the usage from this CDR
+      # while waiting for the lock...
+      next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
+      # steal the usage allocation and flag the old CDR for reprocessing
+      $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
+      # if the allocation is more minutes than we need, adjust it...
+      my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
+      if ( $delta > 0 ) {
+        $cdr_cust_pkg_usage->set('minutes', $minutes);
+        $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
+        $error = $cust_pkg_usage->replace;
+      }
+      #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
+      $error ||= $cdr_cust_pkg_usage->replace;
+      # deduct the stolen minutes
+      $minutes -= $cdr_cust_pkg_usage->minutes;
+    }
+    # after all minute-stealing is done, reset the affected CDRs
+    foreach (values %reproc_cdrs) {
+      $error ||= $_->set_status('');
+      # XXX or should we just call $cdr->rate right here?
+      # it's not like we can create a loop this way, since the min_charge
+      # or call time has to go monotonically in one direction.
+      # we COULD get some very deep recursions going, though...
+    }
+  } # if $order and $minutes
+  if ( $error ) {
+    $dbh->rollback;
+    die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
+  } else {
+    $dbh->commit if $oldAutoCommit;
+    return $orig_minutes - $minutes;
   }
-  return $remaining;
 }
 
-=item reexport
+=item supplemental_pkgs
 
-This method is deprecated.  See the I<depend_jobnum> option to the insert and
-order_pkgs methods in FS::cust_main for a better way to defer provisioning.
+Returns a list of all packages supplemental to this one.
 
 =cut
 
-sub reexport {
+sub supplemental_pkgs {
   my $self = shift;
+  qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
+}
 
-  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;
+=item main_pkg
 
-  foreach my $cust_svc ( $self->cust_svc ) {
-    #false laziness w/svc_Common::insert
-    my $svc_x = $cust_svc->svc_x;
-    foreach my $part_export ( $cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_insert($svc_x);
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-    }
-  }
+Returns the package that this one is supplemental to, if any.
 
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+=cut
 
+sub main_pkg {
+  my $self = shift;
+  if ( $self->main_pkgnum ) {
+    return FS::cust_pkg->by_key($self->main_pkgnum);
+  }
+  return;
 }
 
 =back
@@ -1879,14 +3630,38 @@ 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 )
+"; }
+
+=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 )
 "; }
@@ -1900,6 +3675,7 @@ that are otherwise unsuspended/uncancelled).
 
 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 )
 "; }
@@ -1933,7 +3709,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)
 
@@ -1952,11 +3744,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
 
@@ -1992,99 +3788,247 @@ pkgnum or APKG_pkgnum
 
 =item cust_fields
 
-a value suited to passing to FS::UI::Web::cust_header
+a value suited to passing to FS::UI::Web::cust_header
+
+=item CurrentUser
+
+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 with a service location in the specified state and country.
+For FCC 477 reporting, mostly.
+
+=back
+
+=cut
+
+sub search {
+  my ($class, $params) = @_;
+  my @where = ();
+
+  ##
+  # parse agent
+  ##
+
+  if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_main.agentnum = $1";
+  }
+
+  ##
+  # 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
+  ##
+
+  if (    $params->{'magic'}  eq 'active'
+       || $params->{'status'} eq 'active' ) {
+
+    push @where, FS::cust_pkg->active_sql();
+
+  } 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();
+
+  } elsif (    $params->{'magic'}  eq 'suspended'
+            || $params->{'status'} eq 'suspended'  ) {
+
+    push @where, FS::cust_pkg->suspended_sql();
+
+  } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
+            || $params->{'status'} =~ /^cancell?ed$/ ) {
+
+    push @where, FS::cust_pkg->cancelled_sql();
+
+  }
+
+  ###
+  # parse package class
+  ###
+
+  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 ) {
 
-=item CurrentUser
+      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;
 
-specifies the user for agent virtualization
+      if ( scalar(@c_where) == 1 ) {
+        push @where, @c_where;
+      } elsif ( @c_where ) {
+        push @where, ' ( '. join(' OR ', @c_where). ' ) ';
+      }
 
-=back
+    }
+    
 
-=cut
+  }
 
-sub search_sql { 
-  my ($class, $params) = @_;
-  my @where = ();
+  ###
+  # parse package report options
+  ###
 
-  ##
-  # parse agent
-  ##
+  my @report_option = ();
+  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 ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
-    push @where,
-      "cust_main.agentnum = $1";
   }
 
-  ##
-  # parse status
-  ##
-
-  if (    $params->{'magic'}  eq 'active'
-       || $params->{'status'} eq 'active' ) {
+  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;
+  }
 
-    push @where, FS::cust_pkg->active_sql();
+  foreach my $any ( grep /^report_option_any/, keys %$params ) {
 
-  } elsif (    $params->{'magic'}  eq 'inactive'
-            || $params->{'status'} eq 'inactive' ) {
+    my @report_option_any = ();
+    if ( ref($params->{$any}) eq 'ARRAY' ) {
+      @report_option_any = @{ $params->{$any} };
+    } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
+      @report_option_any = split(',', $1);
+    }
 
-    push @where, FS::cust_pkg->inactive_sql();
+    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
+      ). ' ) ';
+    }
 
-  } elsif (    $params->{'magic'}  eq 'suspended'
-            || $params->{'status'} eq 'suspended'  ) {
+  }
 
-    push @where, FS::cust_pkg->suspended_sql();
+  ###
+  # parse custom
+  ###
 
-  } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
-            || $params->{'status'} =~ /^cancell?ed$/ ) {
+  push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
 
-    push @where, FS::cust_pkg->cancelled_sql();
+  ###
+  # parse fcc_line
+  ###
 
-  } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
+  push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
+                                                        if $params->{fcc_line};
 
-    push @where, FS::cust_pkg->inactive_sql();
+  ###
+  # parse censustract
+  ###
 
+  if ( exists($params->{'censustract'}) ) {
+    $params->{'censustract'} =~ /^([.\d]*)$/;
+    my $censustract = "cust_location.censustract = '$1'";
+    $censustract .= ' OR cust_location.censustract is NULL' unless $1;
+    push @where,  "( $censustract )";
   }
 
   ###
-  # parse package class
+  # parse censustract2
   ###
-
-  #false lazinessish w/graph/cust_bill_pkg.cgi
-  my $classnum = 0;
-  my @pkg_class = ();
-  if ( exists($params->{'classnum'})
-       && $params->{'classnum'} =~ /^(\d*)$/
+  if ( exists($params->{'censustract2'})
+       && $params->{'censustract2'} =~ /^(\d*)$/
      )
   {
-    $classnum = $1;
-    if ( $classnum ) { #a specific class
-      push @where, "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, "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)';
+    if ($1) {
+      push @where, "cust_location.censustract LIKE '$1%'";
     } else {
-      die "illegal classnum";
+      push @where,
+        "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
+    }
+  }
+
+  ###
+  # parse country/state
+  ###
+  for (qw(state country)) { # parsing rules are the same for these
+  if ( exists($params->{$_}) 
+    && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
+    {
+      # XXX post-2.3 only--before that, state/country may be in cust_main
+      push @where, "cust_location.$_ = '$1'";
     }
   }
-  #eslaf
 
   ###
   # 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
@@ -2102,21 +4046,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 change_date 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';
@@ -2165,40 +4120,210 @@ 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');
   }
 
   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
 
-  my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
-                  'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
-                  'LEFT JOIN pkg_class USING ( classnum ) ';
+  my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
+                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
+                  'LEFT JOIN cust_location USING ( locationnum ) '.
+                  FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
 
-  my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
+  my $select;
+  my $count_query;
+  if ( $params->{'select_zip5'} ) {
+    my $zip = 'cust_location.zip';
+
+    $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 tax_locationnum_sql
+
+Returns an SQL expression for the tax location for a package, based
+on the settings of 'tax-pkg_address' and 'tax-ship_address'.
+
+=cut
+
+sub tax_locationnum_sql {
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('tax-pkg_address') ) {
+    'cust_pkg.locationnum';
+  }
+  elsif ( $conf->exists('tax-ship_address') ) {
+    'cust_main.ship_locationnum';
+  }
+  else {
+    'cust_main.bill_locationnum';
+  }
+}
+
+=item location_sql
+
+Returns a list: the first item is an SQL fragment identifying matching 
+packages/customers via location (taking into account shipping and package
+address taxation, if enabled), and subsequent items are the parameters to
+substitute for the placeholders in that fragment.
+
+=cut
+
+sub location_sql {
+  my($class, %opt) = @_;
+  my $ornull = $opt{'ornull'};
+
+  my $conf = new FS::Conf;
+
+  # '?' placeholders in _location_sql_where
+  my $x = $ornull ? 3 : 2;
+  my @bill_param = ( 
+    ('district')x3,
+    ('city')x3, 
+    ('county')x$x,
+    ('state')x$x,
+    'country'
+  );
+
+  my $main_where;
+  my @main_param;
+  if ( $conf->exists('tax-ship_address') ) {
+
+    $main_where = "(
+         (     ( ship_last IS NULL     OR  ship_last  = '' )
+           AND ". _location_sql_where('cust_main', '', $ornull ). "
+         )
+      OR (       ship_last IS NOT NULL AND ship_last != ''
+           AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
+         )
+    )";
+    #    AND payby != 'COMP'
+
+    @main_param = ( @bill_param, @bill_param );
+
+  } else {
+
+    $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
+    @main_param = @bill_param;
+
+  }
+
+  my $where;
+  my @param;
+  if ( $conf->exists('tax-pkg_address') ) {
+
+    my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
+
+    $where = " (
+                    ( cust_pkg.locationnum IS     NULL AND $main_where )
+                 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
+               )
+             ";
+    @param = ( @main_param, @bill_param );
+  
+  } else {
+
+    $where = $main_where;
+    @param = @main_param;
+
+  }
+
+  ( $where, @param );
+
+}
+
+#subroutine, helper for location_sql
+sub _location_sql_where {
+  my $table  = shift;
+  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 )";
+
+  my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
+
+#        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
+  "
+        ( $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
@@ -2246,8 +4371,11 @@ sub order {
   my $dbh = dbh;
 
   my $error;
-  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
-  return "Customer not found: $custnum" unless $cust_main;
+#  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;
@@ -2257,30 +4385,56 @@ sub order {
   my %hash = (); 
   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
 
-    my $time = time;
+    warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
+         " to pkgpart ". $pkgparts->[0]. "\n"
+      if $DEBUG;
 
-    #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
-    
-    #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
-    $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
+    my $err_or_cust_pkg =
+      $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
+                                'refnum'  => $refnum,
+                              );
+
+    unless (ref($err_or_cust_pkg)) {
+      $dbh->rollback if $oldAutoCommit;
+      return $err_or_cust_pkg;
+    }
+
+    push @$return_cust_pkg, $err_or_cust_pkg;
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
 
-    $hash{'change_date'} = $time;
-    $hash{"change_$_"}  = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
   }
 
   # 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,
                                       %hash,
                                     };
     $error = $cust_pkg->insert( 'change' => $change );
+    push @$return_cust_pkg, $cust_pkg;
+
+    foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
+      my $supp_pkg = FS::cust_pkg->new({
+          custnum => $custnum,
+          pkgpart => $link->dst_pkgpart,
+          refnum  => $refnum,
+          main_pkgnum => $cust_pkg->pkgnum,
+          %hash,
+      });
+      $error ||= $supp_pkg->insert( 'change' => $change );
+      push @$return_cust_pkg, $supp_pkg;
+    }
+
     if ($error) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
-    push @$return_cust_pkg, $cust_pkg;
+
   }
   # $return_cust_pkg now contains refs to all of the newly 
   # created packages.
@@ -2288,6 +4442,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) {
@@ -2328,8 +4485,10 @@ sub order {
 
 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
 
+A bulk change method to change packages for multiple customers.
+
 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
-L<FS::part_pkg>) to order for this customer.  Duplicates are of course
+L<FS::part_pkg>) to order for each customer.  Duplicates are of course
 permitted.
 
 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
@@ -2384,117 +4543,28 @@ 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\'',
+    # 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;
   }
 }