start adding package locations, RT#4499
[freeside.git] / FS / FS / cust_pkg.pm
index 571bf0e..03cec75 100644 (file)
@@ -2,6 +2,7 @@ package FS::cust_pkg;
 
 use strict;
 use vars qw(@ISA $disable_agentcheck $DEBUG);
 
 use strict;
 use vars qw(@ISA $disable_agentcheck $DEBUG);
+use Scalar::Util qw( blessed );
 use List::Util qw(max);
 use Tie::IxHash;
 use FS::UID qw( getotaker dbh );
 use List::Util qw(max);
 use Tie::IxHash;
 use FS::UID qw( getotaker dbh );
@@ -12,15 +13,18 @@ use FS::cust_main_Mixin;
 use FS::cust_svc;
 use FS::part_pkg;
 use FS::cust_main;
 use FS::cust_svc;
 use FS::part_pkg;
 use FS::cust_main;
+use FS::cust_location;
 use FS::type_pkgs;
 use FS::pkg_svc;
 use FS::cust_bill_pkg;
 use FS::type_pkgs;
 use FS::pkg_svc;
 use FS::cust_bill_pkg;
+use FS::cust_pkg_detail;
 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_event;
 use FS::h_cust_svc;
 use FS::reg_code;
 use FS::part_svc;
 use FS::cust_pkg_reason;
 use FS::reason;
+use FS::UI::Web;
 
 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
 # setup }
 
 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
 # setup }
@@ -101,36 +105,80 @@ inherits from FS::Record.  The following fields are currently supported:
 
 =over 4
 
 
 =over 4
 
-=item pkgnum - primary key (assigned automatically for new billing items)
+=item pkgnum
 
 
-=item custnum - Customer (see L<FS::cust_main>)
+Primary key (assigned automatically for new billing items)
 
 
-=item pkgpart - Billing item definition (see L<FS::part_pkg>)
+=item custnum
 
 
-=item setup - date
+Customer (see L<FS::cust_main>)
 
 
-=item bill - date (next bill date)
+=item pkgpart
 
 
-=item last_bill - last bill date
+Billing item definition (see L<FS::part_pkg>)
 
 
-=item adjourn - date
+=item locationnum
 
 
-=item susp - date
+Optional link to package location (see L<FS::location>)
 
 
-=item expire - date
+=item setup
 
 
-=item cancel - date
+date
 
 
-=item otaker - order taker (assigned automatically if null, see L<FS::UID>)
+=item bill
 
 
-=item manual_flag - If this field is set to 1, disables the automatic
-unsuspension of this package when using the B<unsuspendauto> config file.
+date (next bill date)
+
+=item last_bill
+
+last bill date
+
+=item adjourn
+
+date
+
+=item susp
+
+date
+
+=item expire
+
+date
+
+=item cancel
+
+date
+
+=item otaker
+
+order taker (assigned automatically if null, see L<FS::UID>)
+
+=item manual_flag
+
+If this field is set to 1, disables the automatic
+unsuspension of this package when using the B<unsuspendauto> config option.
+
+=item quantity
+
+If not set, defaults to 1
+
+=item change_date
+
+Date of change from previous package
+
+=item change_pkgnum
+
+Previous pkgnum
+
+=item change_pkgpart
+
+Previous pkgpart
 
 =back
 
 
 =back
 
-Note: setup, bill, adjourn, susp, expire and cancel are specified as UNIX timestamps;
-see L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for
-conversion functions.
+Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
+are specified as UNIX timestamps; see L<perlfunc/"time">.  Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =head1 METHODS
 
 
 =head1 METHODS
 
@@ -165,9 +213,19 @@ setting I<refnum> to an array reference of refnums or a hash reference with
 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
 record will be created corresponding to cust_main.refnum.
 
 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
 record will be created corresponding to cust_main.refnum.
 
-The following options are available: I<change>
+The following options are available:
+
+=over 4
+
+=item change
+
+If set true, supresses any referral credit to a referring customer.
+
+=item options
 
 
-I<change>, if set true, supresses any referral credit to a referring customer.
+cust_pkg_option records will be created
+
+=back
 
 =cut
 
 
 =cut
 
@@ -208,40 +266,6 @@ sub insert {
   #}
 
   my $conf = new FS::Conf;
   #}
 
   my $conf = new FS::Conf;
-  my $cust_main = $self->cust_main;
-  my $part_pkg = $self->part_pkg;
-  if ( $conf->exists('referral_credit')
-       && $cust_main->referral_custnum
-       && ! $options{'change'}
-       && $part_pkg->freq !~ /^0\D?$/
-     )
-  {
-    my $referring_cust_main = $cust_main->referring_cust_main;
-    if ( $referring_cust_main->status ne 'cancelled' ) {
-      my $error;
-      if ( $part_pkg->freq !~ /^\d+$/ ) {
-        warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
-             ' for package '. $self->pkgnum.
-             ' ( customer '. $self->custnum. ')'.
-             ' - One-time referral credits not (yet) available for '.
-             ' packages with '. $part_pkg->freq_pretty. ' frequency';
-      } else {
-
-        my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq );
-        my $error =
-          $referring_cust_main->credit( $amount,
-                                        'Referral credit for '. $cust_main->name
-                                      );
-        if ( $error ) {
-          $dbh->rollback if $oldAutoCommit;
-          return "Error crediting customer ". $cust_main->referral_custnum.
-               " for referral: $error";
-        }
-
-      }
-
-    }
-  }
 
   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
     my $queue = new FS::queue {
 
   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
     my $queue = new FS::queue {
@@ -276,7 +300,7 @@ the customer ever purchased the item.  Instead, see the cancel method.
 #  return "Can't delete cust_pkg records!";
 #}
 
 #  return "Can't delete cust_pkg records!";
 #}
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
@@ -293,17 +317,38 @@ suspend is normally updated by the suspend and unsuspend methods.
 cancel is normally updated by the cancel method (and also the order subroutine
 in some cases).
 
 cancel is normally updated by the cancel method (and also the order subroutine
 in some cases).
 
-Calls 
+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 options
+
+hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
+
+=back
 
 =cut
 
 sub replace {
 
 =cut
 
 sub replace {
-  my( $new, $old, %options ) = @_;
+  my $new = shift;
+
+  my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
+              ? shift
+              : $new->replace_old;
+
+  my $options = 
+    ( ref($_[0]) eq 'HASH' )
+      ? shift
+      : { @_ };
 
 
-  # We absolutely have to have an old vs. new record to make this work.
-  if (!defined($old)) {
-    $old = qsearchs( 'cust_pkg', { 'pkgnum' => $new->pkgnum } );
-  }
   #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 (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
   return "Can't change otaker!" if $old->otaker ne $new->otaker;
 
@@ -328,10 +373,13 @@ sub replace {
   my $dbh = dbh;
 
   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
   my $dbh = dbh;
 
   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
-    if ($options{'reason'} && $new->$method && $old->$method ne $new->$method) {
-      my $error = $new->insert_reason( 'reason' => $options{'reason'},
-                                       'date'   => $new->$method,
-                                     );
+    if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
+      my $error = $new->insert_reason(
+        'reason'        => $options->{'reason'},
+        'date'          => $new->$method,
+        'action'        => $method,
+        'reason_otaker' => $options->{'reason_otaker'},
+      );
       if ( $error ) {
         dbh->rollback if $oldAutoCommit;
         return "Error inserting cust_pkg_reason: $error";
       if ( $error ) {
         dbh->rollback if $oldAutoCommit;
         return "Error inserting cust_pkg_reason: $error";
@@ -354,7 +402,7 @@ sub replace {
   }
 
   my $error = $new->SUPER::replace($old,
   }
 
   my $error = $new->SUPER::replace($old,
-                                   $options{options} ? ${options{options}} : ()
+                                   $options->{options} ? $options->{options} : ()
                                   );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
                                   );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
@@ -392,6 +440,7 @@ sub check {
     $self->ut_numbern('pkgnum')
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_numbern('pkgpart')
     $self->ut_numbern('pkgnum')
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_numbern('pkgpart')
+    || $self->ut_foreign_keyn('locationnum', 'location', 'locationnum')
     || $self->ut_numbern('setup')
     || $self->ut_numbern('bill')
     || $self->ut_numbern('susp')
     || $self->ut_numbern('setup')
     || $self->ut_numbern('bill')
     || $self->ut_numbern('susp')
@@ -437,7 +486,7 @@ sub check {
   }
 
   $self->otaker(getotaker) unless $self->otaker;
   }
 
   $self->otaker(getotaker) unless $self->otaker;
-  $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
+  $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
   $self->otaker($1);
 
   if ( $self->dbdef_table->column('manual_flag') ) {
   $self->otaker($1);
 
   if ( $self->dbdef_table->column('manual_flag') ) {
@@ -466,6 +515,8 @@ Available options are:
 
 =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)
+
 =back
 
 If there is an error, returns the error, otherwise returns false.
 =back
 
 If there is an error, returns the error, otherwise returns false.
@@ -474,6 +525,7 @@ If there is an error, returns the error, otherwise returns false.
 
 sub cancel {
   my( $self, %options ) = @_;
 
 sub cancel {
   my( $self, %options ) = @_;
+  my $error;
 
   warn "cust_pkg::cancel called with options".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
 
   warn "cust_pkg::cancel called with options".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
@@ -490,12 +542,24 @@ sub cancel {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
   
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
   
-  my $cancel_time = $options{'time'} || time;
+  my $old = $self->select_for_update;
 
 
-  my $error;
+  if ( $old->get('cancel') || $self->get('cancel') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "";  # no error
+  }
+
+  my $date = $options{date} if $options{date}; # expire/cancel later
+  $date = '' if ($date && $date <= time);      # complain instead?
+
+  my $cancel_time = $options{'time'} || time;
 
   if ( $options{'reason'} ) {
 
   if ( $options{'reason'} ) {
-    $error = $self->insert_reason( 'reason' => $options{'reason'} );
+    $error = $self->insert_reason( 'reason' => $options{'reason'},
+                                   'action' => $date ? 'expire' : 'cancel',
+                                   'date'   => $date ? $date : $cancel_time,
+                                   'reason_otaker' => $options{'reason_otaker'},
+                                 );
     if ( $error ) {
       dbh->rollback if $oldAutoCommit;
       return "Error inserting cust_pkg_reason: $error";
     if ( $error ) {
       dbh->rollback if $oldAutoCommit;
       return "Error inserting cust_pkg_reason: $error";
@@ -503,47 +567,51 @@ sub cancel {
   }
 
   my %svc;
   }
 
   my %svc;
-  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 } )
-  ) {
+  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 $error = $cust_svc->cancel;
 
 
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "Error cancelling cust_svc: $error";
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error cancelling cust_svc: $error";
+      }
     }
     }
-  }
 
 
-  unless ( $self->getfield('cancel') ) {
     # Add a credit for remaining service
     my $remaining_value = $self->calc_remain(time=>$cancel_time);
     if ( $remaining_value > 0 && !$options{'no_credit'} ) {
     # Add a credit for remaining service
     my $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(
       my $error = $self->cust_main->credit(
-                                           $remaining_value,
-                                           'Credit for unused time on '. $self->part_pkg->pkg,
-                                           );
+        $remaining_value,
+        'Credit for unused time on '. $self->part_pkg->pkg,
+        'reason_type' => $conf->config('cancel_credit_type'),
+      );
       if ($error) {
         $dbh->rollback if $oldAutoCommit;
         return "Error crediting customer \$$remaining_value for unused time on".
       if ($error) {
         $dbh->rollback if $oldAutoCommit;
         return "Error crediting customer \$$remaining_value for unused time on".
-          $self->part_pkg->pkg. ": $error";
-      }                                                                          
-    }                                                                            
-    my %hash = $self->hash;
-    $hash{'cancel'} = $cancel_time;
-    my $new = new FS::cust_pkg ( \%hash );
-    $error = $new->replace( $self, options => { $self->options } );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+               $self->part_pkg->pkg. ": $error";
+      }
     }
   }
 
     }
   }
 
+  my %hash = $self->hash;
+  $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
+  my $new = new FS::cust_pkg ( \%hash );
+  $error = $new->replace( $self, options => { $self->options } );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   $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;
 
   my $conf = new FS::Conf;
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
@@ -552,7 +620,7 @@ sub cancel {
     my $error = send_email(
       'from'    => $conf->config('invoice_from'),
       'to'      => \@invoicing_list,
     my $error = send_email(
       'from'    => $conf->config('invoice_from'),
       'to'      => \@invoicing_list,
-      'subject' => $conf->config('cancelsubject'),
+      'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
       'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
     );
     #should this do something on errors?
       'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
     );
     #should this do something on errors?
@@ -580,7 +648,59 @@ sub cancel_if_expired {
   '';
 }
 
   '';
 }
 
-=item suspend  [ OPTION => VALUE ... ]
+=item unexpire
+
+Cancels any pending expiration (sets the expire field to null).
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub unexpire {
+  my( $self, %options ) = @_;
+  my $error;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $old = $self->select_for_update;
+
+  my $pkgnum = $old->pkgnum;
+  if ( $old->get('cancel') || $self->get('cancel') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "Can't unexpire cancelled package $pkgnum";
+    # or at least it's pointless
+  }
+
+  unless ( $old->get('expire') && $self->get('expire') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "";  # no error
+  }
+
+  my %hash = $self->hash;
+  $hash{'expire'} = '';
+  my $new = new FS::cust_pkg ( \%hash );
+  $error = $new->replace( $self, options => { $self->options } );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  ''; #no errors
+
+}
+
+=item suspend [ OPTION => VALUE ... ]
 
 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
 package, then suspends the package itself (sets the susp field to now).
 
 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
 package, then suspends the package itself (sets the susp field to now).
@@ -591,6 +711,8 @@ Available options are:
 
 =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)
+
 =back
 
 If there is an error, returns the error, otherwise returns false.
 =back
 
 If there is an error, returns the error, otherwise returns false.
@@ -599,6 +721,7 @@ If there is an error, returns the error, otherwise returns false.
 
 sub suspend {
   my( $self, %options ) = @_;
 
 sub suspend {
   my( $self, %options ) = @_;
+  my $error;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -611,48 +734,106 @@ sub suspend {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error;
+  my $old = $self->select_for_update;
+
+  my $pkgnum = $old->pkgnum;
+  if ( $old->get('cancel') || $self->get('cancel') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "Can't suspend cancelled package $pkgnum";
+  }
+
+  if ( $old->get('susp') || $self->get('susp') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "";  # no error                     # complain on adjourn?
+  }
+
+  my $date = $options{date} if $options{date}; # adjourn/suspend later
+  $date = '' if ($date && $date <= time);      # complain instead?
+
+  if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
+    dbh->rollback if $oldAutoCommit;
+    return "Package $pkgnum expires before it would be suspended.";
+  }
+
+  my $suspend_time = $options{'time'} || time;
 
   if ( $options{'reason'} ) {
 
   if ( $options{'reason'} ) {
-    $error = $self->insert_reason( 'reason' => $options{'reason'} );
+    $error = $self->insert_reason( 'reason' => $options{'reason'},
+                                   'action' => $date ? 'adjourn' : 'suspend',
+                                   'date'   => $date ? $date : $suspend_time,
+                                   'reason_otaker' => $options{'reason_otaker'},
+                                 );
     if ( $error ) {
       dbh->rollback if $oldAutoCommit;
       return "Error inserting cust_pkg_reason: $error";
     }
   }
 
     if ( $error ) {
       dbh->rollback if $oldAutoCommit;
       return "Error inserting cust_pkg_reason: $error";
     }
   }
 
-  foreach my $cust_svc (
-    qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
-  ) {
-    my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
+  unless ( $date ) {
 
 
-    $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
-      $dbh->rollback if $oldAutoCommit;
-      return "Illegal svcdb value in part_svc!";
-    };
-    my $svcdb = $1;
-    require "FS/$svcdb.pm";
+    my @labels = ();
 
 
-    my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
-    if ($svc) {
-      $error = $svc->suspend;
-      if ( $error ) {
+    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;
         $dbh->rollback if $oldAutoCommit;
-        return $error;
+        return "Illegal svcdb value in part_svc!";
+      };
+      my $svcdb = $1;
+      require "FS/$svcdb.pm";
+
+      my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
+      if ($svc) {
+        $error = $svc->suspend;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+        my( $label, $value ) = $cust_svc->label;
+        push @labels, "$label: $value";
       }
     }
 
       }
     }
 
-  }
+    my $conf = new FS::Conf;
+    if ( $conf->config('suspend_email_admin') ) {
+      my $error = send_email(
+        'from'    => $conf->config('invoice_from'), #??? well as good as any
+        'to'      => $conf->config('suspend_email_admin'),
+        'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
+        'body'    => [
+          "This is an automatic message from your Freeside installation\n",
+          "informing you that the following customer package has been suspended:\n",
+          "\n",
+          'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
+          'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
+          ( map { "Service : $_\n" } @labels ),
+        ],
+      );
+
+      if ( $error ) {
+        warn "WARNING: can't send suspension admin email (suspending anyway): ".
+             "$error\n";
+      }
 
 
-  unless ( $self->getfield('susp') ) {
-    my %hash = $self->hash;
-    $hash{'susp'} = time;
-    my $new = new FS::cust_pkg ( \%hash );
-    $error = $new->replace( $self, options => { $self->options } );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
     }
     }
+
+  }
+
+  my %hash = $self->hash;
+  if ( $date ) {
+    $hash{'adjourn'} = $date;
+  } else {
+    $hash{'susp'} = $suspend_time;
+  }
+  my $new = new FS::cust_pkg ( \%hash );
+  $error = $new->replace( $self, options => { $self->options } );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -666,15 +847,21 @@ 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).
 
 package, then unsuspends the package itself (clears the susp field and the
 adjourn field if it is in the past).
 
-Available options are: I<adjust_next_bill>.
+Available options are:
+
+=over 4
+
+=item adjust_next_bill
 
 
-I<adjust_next_bill> can be set true to adjust the next bill date forward by
+Can be set true to adjust the next bill date forward by
 the amount of time the account was inactive.  This was set true by default
 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
 explicitly requested.  Price plans for which this makes sense (anniversary-date
 based than prorate or subscription) could have an option to enable this
 behaviour?
 
 the amount of time the account was inactive.  This was set true by default
 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
 explicitly requested.  Price plans for which this makes sense (anniversary-date
 based than prorate or subscription) could have an option to enable this
 behaviour?
 
+=back
+
 If there is an error, returns the error, otherwise returns false.
 
 =cut
 If there is an error, returns the error, otherwise returns false.
 
 =cut
@@ -694,6 +881,19 @@ sub unsuspend {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  my $old = $self->select_for_update;
+
+  my $pkgnum = $old->pkgnum;
+  if ( $old->get('cancel') || $self->get('cancel') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "Can't unsuspend cancelled package $pkgnum";
+  }
+
+  unless ( $old->get('susp') && $self->get('susp') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "";  # no error                     # complain instead?
+  }
+
   foreach my $cust_svc (
     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
   ) {
   foreach my $cust_svc (
     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
   ) {
@@ -717,25 +917,23 @@ sub unsuspend {
 
   }
 
 
   }
 
-  unless ( ! $self->getfield('susp') ) {
-    my %hash = $self->hash;
-    my $inactive = time - $hash{'susp'};
+  my %hash = $self->hash;
+  my $inactive = time - $hash{'susp'};
 
 
-    my $conf = new FS::Conf;
+  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'} );
+  $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'} );
 
 
-    $hash{'susp'} = '';
-    $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
-    my $new = new FS::cust_pkg ( \%hash );
-    $error = $new->replace( $self, options => { $self->options } );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
+  $hash{'susp'} = '';
+  $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
+  my $new = new FS::cust_pkg ( \%hash );
+  $error = $new->replace( $self, options => { $self->options } );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -743,6 +941,64 @@ sub unsuspend {
   ''; #no errors
 }
 
   ''; #no errors
 }
 
+=item unadjourn
+
+Cancels any pending suspension (sets the adjourn field to null).
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub unadjourn {
+  my( $self, %options ) = @_;
+  my $error;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE'; 
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $old = $self->select_for_update;
+
+  my $pkgnum = $old->pkgnum;
+  if ( $old->get('cancel') || $self->get('cancel') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "Can't unadjourn cancelled package $pkgnum";
+    # or at least it's pointless
+  }
+
+  if ( $old->get('susp') || $self->get('susp') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "Can't unadjourn suspended package $pkgnum";
+    # perhaps this is arbitrary
+  }
+
+  unless ( $old->get('adjourn') && $self->get('adjourn') ) {
+    dbh->rollback if $oldAutoCommit;
+    return "";  # no error
+  }
+
+  my %hash = $self->hash;
+  $hash{'adjourn'} = '';
+  my $new = new FS::cust_pkg ( \%hash );
+  $error = $new->replace( $self, options => { $self->options } );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  ''; #no errors
+
+}
+
 =item last_bill
 
 Returns the last bill date, or if there is no last bill date, the setup date.
 =item last_bill
 
 Returns the last bill date, or if there is no last bill date, the setup date.
@@ -752,29 +1008,45 @@ Useful for billing metered services.
 
 sub last_bill {
   my $self = shift;
 
 sub last_bill {
   my $self = shift;
-  if ( $self->dbdef_table->column('last_bill') ) {
-    return $self->setfield('last_bill', $_[0]) if @_;
-    return $self->getfield('last_bill') if $self->getfield('last_bill');
-  }    
+  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;
 }
 
   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_reason
+=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.
+
+=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 last_reason ACTION
 
 
-Returns the most recent FS::reason associated with the package.
+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 last_reason {
 
 =cut
 
 sub last_reason {
-  my $self = shift;
-  my $cust_pkg_reason = qsearchs( {
-                                    'table' => 'cust_pkg_reason',
-                                   'hashref' => { 'pkgnum' => $self->pkgnum, },
-                                   'extra_sql'=> 'ORDER BY date DESC LIMIT 1',
-                                 } );
-  qsearchs ( 'reason', { 'reasonnum' => $cust_pkg_reason->reasonnum } )
+  my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
+  $cust_pkg_reason->reason
     if $cust_pkg_reason;
 }
 
     if $cust_pkg_reason;
 }
 
@@ -793,6 +1065,18 @@ sub part_pkg {
     : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
 }
 
     : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
 }
 
+=item old_cust_pkg
+
+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 calc_setup
 
 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
 =item calc_setup
 
 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
@@ -852,6 +1136,77 @@ sub cust_bill_pkg {
   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
 }
 
   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
 }
 
+=item cust_pkg_detail [ DETAILTYPE ]
+
+Returns any customer package details for this package (see
+L<FS::cust_pkg_detail>).
+
+DETAILTYPE can be set to "I" for invoice details or "C" for comments.
+
+=cut
+
+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',
+  });
+}
+
+=item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
+
+Sets customer package details for this package (see L<FS::cust_pkg_detail>).
+
+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
+
+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';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
+    my $error = $current->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error removing old detail: $error";
+    }
+  }
+
+  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;
+  '';
+
+}
+
 =item cust_event
 
 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
 =item cust_event
 
 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
@@ -1176,24 +1531,29 @@ sub h_labels {
 
 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
 
 
 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
 
-Like h_labels, except returns a simple flat list, and shortens long 
-(currently >5) lists of identical services to one line that lists the service
-label and the number of individual services rather than individual items.
+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 {
   my $self = shift;
 
 
 =cut
 
 sub h_labels_short {
   my $self = shift;
 
+  my $conf = new FS::Conf;
+  my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
+
   my %labels;
   #tie %labels, 'Tie::IxHash';
   push @{ $labels{$_->[0]} }, $_->[1]
     foreach $self->h_labels(@_);
   my @labels;
   foreach my $label ( keys %labels ) {
   my %labels;
   #tie %labels, 'Tie::IxHash';
   push @{ $labels{$_->[0]} }, $_->[1]
     foreach $self->h_labels(@_);
   my @labels;
   foreach my $label ( keys %labels ) {
-    my @values = @{ $labels{$label} };
+    my %seen = ();
+    my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
     my $num = scalar(@values);
     my $num = scalar(@values);
-    if ( $num > 5 ) {
+    if ( $num > $max_same_services ) {
       push @labels, "$label ($num)";
     } else {
       push @labels, map { "$label: $_" } @values;
       push @labels, "$label ($num)";
     } else {
       push @labels, map { "$label: $_" } @values;
@@ -1215,6 +1575,30 @@ sub cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
+=item cust_location
+
+Returns the location object, if any (see L<FS::cust_location>).
+
+=cut
+
+sub cust_location {
+  my $self = shift;
+  return '' unless $self->locationnum;
+  qsearchs( 'cust_main', { 'locationnum' => $self->locationnum } );
+}
+
+=item cust_location_or_main
+
+If this package is associated with a location, returns the locaiton (see
+L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
+
+=cut
+
+sub cust_location_or_main {
+  my $self = shift;
+  $self->cust_location || $self->cust_main;
+}
+
 =item seconds_since TIMESTAMP
 
 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
 =item seconds_since TIMESTAMP
 
 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
@@ -1303,6 +1687,18 @@ sub attribute_since_sqlradacct {
 
 }
 
 
 }
 
+=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.
 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
 
 Transfers as many services as possible from this package to another package.
@@ -1535,6 +1931,272 @@ sub cancel_sql {
   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
 }
 
   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
 }
 
+=item search_sql HASHREF
+
+(Class method)
+
+Returns a qsearch hash expression to search for parameters specified in HASHREF.
+Valid parameters are
+
+=over 4
+
+=item agentnum
+
+=item magic
+
+active, inactive, suspended, cancel (or cancelled)
+
+=item status
+
+active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
+
+=item classnum
+
+=item pkgpart
+
+list specified how?
+
+=item setup
+
+arrayref of beginning and ending epoch date
+
+=item last_bill
+
+arrayref of beginning and ending epoch date
+
+=item bill
+
+arrayref of beginning and ending epoch date
+
+=item adjourn
+
+arrayref of beginning and ending epoch date
+
+=item susp
+
+arrayref of beginning and ending epoch date
+
+=item expire
+
+arrayref of beginning and ending epoch date
+
+=item cancel
+
+arrayref of beginning and ending epoch date
+
+=item query
+
+pkgnum or APKG_pkgnum
+
+=item cust_fields
+
+a value suited to passing to FS::UI::Web::cust_header
+
+=item CurrentUser
+
+specifies the user for agent virtualization
+
+=back
+
+=cut
+
+sub search_sql { 
+  my ($class, $params) = @_;
+  my @where = ();
+
+  ##
+  # parse agent
+  ##
+
+  if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_main.agentnum = $1";
+  }
+
+  ##
+  # parse status
+  ##
+
+  if (    $params->{'magic'}  eq 'active'
+       || $params->{'status'} eq 'active' ) {
+
+    push @where, FS::cust_pkg->active_sql();
+
+  } elsif (    $params->{'magic'}  eq 'inactive'
+            || $params->{'status'} eq '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();
+
+  } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
+
+    push @where, FS::cust_pkg->inactive_sql();
+
+  }
+
+  ###
+  # parse package class
+  ###
+
+  #false lazinessish w/graph/cust_bill_pkg.cgi
+  my $classnum = 0;
+  my @pkg_class = ();
+  if ( exists($params->{'classnum'})
+       && $params->{'classnum'} =~ /^(\d*)$/
+     )
+  {
+    $classnum = $1;
+    if ( $classnum ) { #a specific class
+      push @where, "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)';
+    } else {
+      die "illegal classnum";
+    }
+  }
+  #eslaf
+
+  ###
+  # parse part_pkg
+  ###
+
+  my $pkgpart = join (' OR pkgpart=',
+                      grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
+  push @where,  '(pkgpart=' . $pkgpart . ')' if $pkgpart;
+
+  ###
+  # parse dates
+  ###
+
+  my $orderby = '';
+
+  #false laziness w/report_cust_pkg.html
+  my %disable = (
+    'all'             => {},
+    'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
+    'active'          => { 'susp'=>1, 'cancel'=>1 },
+    'suspended'       => { 'cancel' => 1 },
+    'cancelled'       => {},
+    ''                => {},
+  );
+
+  foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
+
+    next unless exists($params->{$field});
+
+    my($beginning, $ending) = @{$params->{$field}};
+
+    next if $beginning == 0 && $ending == 4294967295;
+
+    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 bill';
+
+  ###
+  # parse magic, legacy, etc.
+  ###
+
+  if ( $params->{'magic'} &&
+       $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
+  ) {
+
+    $orderby = 'ORDER BY pkgnum';
+
+    if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
+      push @where, "pkgpart = $1";
+    }
+
+  } elsif ( $params->{'query'} eq 'pkgnum' ) {
+
+    $orderby = 'ORDER BY pkgnum';
+
+  } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
+
+    $orderby = 'ORDER BY pkgnum';
+
+    push @where, '0 < (
+      SELECT count(*) FROM pkg_svc
+       WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
+         AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
+                                   WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
+                                     AND cust_svc.svcpart = pkg_svc.svcpart
+                                )
+    )';
+  
+  }
+
+  ##
+  # setup queries, links, subs, etc. for the search
+  ##
+
+  # here is the agent virtualization
+  if ($params->{CurrentUser}) {
+    my $access_user =
+      qsearchs('access_user', { username => $params->{CurrentUser} });
+
+    if ($access_user) {
+      push @where, $access_user->agentnums_sql('table'=>'cust_main');
+    }else{
+      push @where, "1=0";
+    }
+  }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 $count_query = "SELECT COUNT(*) 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",
+    'addl_from'   => $addl_from,
+    'count_query' => $count_query,
+  };
+
+}
+
 =head1 SUBROUTINES
 
 =over 4
 =head1 SUBROUTINES
 
 =over 4
@@ -1591,9 +2253,17 @@ sub order {
   my $change = scalar(@old_cust_pkg) != 0;
 
   my %hash = (); 
   my $change = scalar(@old_cust_pkg) != 0;
 
   my %hash = (); 
-  if ( scalar(@old_cust_pkg) == 1 ) {
+  if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
+
+    my $time = time;
+
     #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
     #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
-    $hash{'setup'} = time;
+    
+    #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
+    $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
+
+    $hash{'change_date'} = $time;
+    $hash{"change_$_"}  = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
   }
 
   # Create the new packages.
   }
 
   # Create the new packages.
@@ -1654,6 +2324,64 @@ sub order {
   '';
 }
 
   '';
 }
 
+=item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
+
+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
+permitted.
+
+REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
+replace.  The services (see L<FS::cust_svc>) are moved to the
+new billing items.  An error is returned if this is not possible (see
+L<FS::pkg_svc>).
+
+RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
+newly-created cust_pkg objects.
+
+=cut
+
+sub bulk_change {
+  my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
+
+  # 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'; 
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my @errors;
+  my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
+                         @$remove_pkgnum;
+
+  while(scalar(@old_cust_pkg)) {
+    my @return = ();
+    my $custnum = $old_cust_pkg[0]->custnum;
+    my (@remove) = map { $_->pkgnum }
+                   grep { $_->custnum == $custnum } @old_cust_pkg;
+    @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
+
+    my $error = order $custnum, $pkgparts, \@remove, \@return;
+
+    push @errors, $error
+      if $error;
+    push @$return_cust_pkg, @return;
+  }
+
+  if (scalar(@errors)) {
+    $dbh->rollback if $oldAutoCommit;
+    return join(' / ', @errors);
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+}
+
 =item insert_reason
 
 Associates this package with a (suspension or cancellation) reason (see
 =item insert_reason
 
 Associates this package with a (suspension or cancellation) reason (see
@@ -1664,10 +2392,22 @@ Available options are:
 
 =over 4
 
 
 =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 reason_otaker
+
+the access_user (see L<FS::access_user>) providing the reason
 
 =item date
 
 
 =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.
 =back
 
 If there is an error, returns the error, otherwise returns false.
@@ -1677,7 +2417,8 @@ If there is an error, returns the error, otherwise returns false.
 sub insert_reason {
   my ($self, %options) = @_;
 
 sub insert_reason {
   my ($self, %options) = @_;
 
-  my $otaker = $FS::CurrentUser::CurrentUser->username;
+  my $otaker = $options{reason_otaker} ||
+               $FS::CurrentUser::CurrentUser->username;
 
   my $reasonnum;
   if ( $options{'reason'} =~ /^(\d+)$/ ) {
 
   my $reasonnum;
   if ( $options{'reason'} =~ /^(\d+)$/ ) {
@@ -1704,8 +2445,9 @@ sub insert_reason {
 
   my $cust_pkg_reason =
     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
 
   my $cust_pkg_reason =
     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
-                              'reasonnum' => $options{'reason'}
+                              'reasonnum' => $reasonnum
                              'otaker'    => $otaker,
                              'otaker'    => $otaker,
+                             'action'    => substr(uc($options{'action'}),0,1),
                              'date'      => $options{'date'}
                                               ? $options{'date'}
                                               : time,
                              'date'      => $options{'date'}
                                               ? $options{'date'}
                                               : time,
@@ -1734,6 +2476,26 @@ sub 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");
+  }
+}
+
 =back
 
 =head1 BUGS
 =back
 
 =head1 BUGS