use FS::queue for exports, though they should probably just use it themselves
[freeside.git] / FS / FS / cust_pkg.pm
index 0b1a291..6807baf 100644 (file)
@@ -2,6 +2,7 @@ package FS::cust_pkg;
 
 use strict;
 use vars qw(@ISA $disable_agentcheck @SVCDB_CANCEL_SEQ $DEBUG);
 
 use strict;
 use vars qw(@ISA $disable_agentcheck @SVCDB_CANCEL_SEQ $DEBUG);
+use Tie::IxHash;
 use FS::UID qw( getotaker dbh );
 use FS::Misc qw( send_email );
 use FS::Record qw( qsearch qsearchs );
 use FS::UID qw( getotaker dbh );
 use FS::Misc qw( send_email );
 use FS::Record qw( qsearch qsearchs );
@@ -14,10 +15,11 @@ use FS::pkg_svc;
 use FS::cust_bill_pkg;
 use FS::h_cust_svc;
 use FS::reg_code;
 use FS::cust_bill_pkg;
 use FS::h_cust_svc;
 use FS::reg_code;
+use FS::cust_pkg_reason;
 
 # 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 }
-# because they load configuraion by setting FS::UID::callback (see TODO)
+# because they load configuration by setting FS::UID::callback (see TODO)
 use FS::svc_acct;
 use FS::svc_domain;
 use FS::svc_www;
 use FS::svc_acct;
 use FS::svc_domain;
 use FS::svc_www;
@@ -264,10 +266,12 @@ 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 
+
 =cut
 
 sub replace {
 =cut
 
 sub replace {
-  my( $new, $old ) = ( shift, shift );
+  my( $new, $old, %options ) = @_;
 
   #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;
@@ -281,7 +285,61 @@ sub replace {
 
   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
 
 
   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
 
-  $new->SUPER::replace($old);
+  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;
+
+  if ($options{'reason'} && $new->expire && $old->expire ne $new->expire) {
+    my $error = $new->insert_reason( 'reason' => $options{'reason'},
+                                     'date'      => $new->expire,
+                                   );
+    if ( $error ) {
+      dbh->rollback if $oldAutoCommit;
+      return "Error inserting cust_pkg_reason: $error";
+    }
+  }
+
+  #save off and freeze RADIUS attributes for any associated svc_acct records
+  my @svc_acct = ();
+  if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
+
+                #also check for specific exports?
+                # to avoid spurious modify export events
+    @svc_acct = map  { $_->svc_x }
+                grep { $_->part_svc->svcdb eq 'svc_acct' }
+                     $old->cust_svc;
+
+    $_->snapshot foreach @svc_acct;
+
+  }
+
+  my $error = $new->SUPER::replace($old);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  #for prepaid packages,
+  #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);
+    if ( $s_error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $s_error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
 }
 
 =item check
 }
 
 =item check
@@ -313,7 +371,7 @@ sub check {
              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
                                      'agentnum' => $self->cust_main->agentnum })
            ) {
              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
                                      'agentnum' => $self->cust_main->agentnum })
            ) {
-      return "Unknown registraiton code";
+      return "Unknown registration code";
     }
 
   } elsif ( $self->promo_code ) {
     }
 
   } elsif ( $self->promo_code ) {
@@ -384,6 +442,14 @@ sub cancel {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  if ($options{'reason'}) {
+    $error = $self->insert_reason( 'reason' => $options{'reason'} );
+    if ( $error ) {
+      dbh->rollback if $oldAutoCommit;
+      return "Error inserting cust_pkg_reason: $error";
+    }
+  }
+
   my %svc;
   foreach my $cust_svc (
       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
   my %svc;
   foreach my $cust_svc (
       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
@@ -407,7 +473,7 @@ sub cancel {
   if ( $remaining_value > 0 ) {
     my $error = $self->cust_main->credit(
       $remaining_value,
   if ( $remaining_value > 0 ) {
     my $error = $self->cust_main->credit(
       $remaining_value,
-      'Credit for unused time on'. $self->part_pkg->pkg,
+      'Credit for unused time on '. $self->part_pkg->pkg,
     );
     if ($error) {
       $dbh->rollback if $oldAutoCommit;
     );
     if ($error) {
       $dbh->rollback if $oldAutoCommit;
@@ -456,7 +522,7 @@ If there is an error, returns the error, otherwise returns false.
 =cut
 
 sub suspend {
 =cut
 
 sub suspend {
-  my $self = shift;
+  my( $self, %options ) = @_;
   my $error ;
 
   local $SIG{HUP} = 'IGNORE';
   my $error ;
 
   local $SIG{HUP} = 'IGNORE';
@@ -470,6 +536,14 @@ sub suspend {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  if ($options{'reason'}) {
+    $error = $self->insert_reason( 'reason' => $options{'reason'} );
+    if ( $error ) {
+      dbh->rollback if $oldAutoCommit;
+      return "Error inserting cust_pkg_reason: $error";
+    }
+  }
+
   foreach my $cust_svc (
     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
   ) {
   foreach my $cust_svc (
     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
   ) {
@@ -509,18 +583,27 @@ sub suspend {
   ''; #no errors
 }
 
   ''; #no errors
 }
 
-=item unsuspend
+=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).
 
 
 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).
 
+Available options are: I<adjust_next_bill>.
+
+I<adjust_next_bill> 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?
+
 If there is an error, returns the error, otherwise returns false.
 
 =cut
 
 sub unsuspend {
 If there is an error, returns the error, otherwise returns false.
 
 =cut
 
 sub unsuspend {
-  my $self = shift;
-  my($error);
+  my( $self, %opt ) = @_;
+  my $error;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -559,9 +642,15 @@ sub unsuspend {
   unless ( ! $self->getfield('susp') ) {
     my %hash = $self->hash;
     my $inactive = time - $hash{'susp'};
   unless ( ! $self->getfield('susp') ) {
     my %hash = $self->hash;
     my $inactive = time - $hash{'susp'};
-    $hash{'susp'} = '';
+
+    my $conf = new FS::Conf;
+
     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
-      if $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
+      if ( $opt{'adjust_next_bill'}
+           || $conf->config('unsuspend-always_adjust_next_bill_date') )
+      && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
+
+    $hash{'susp'} = '';
     my $new = new FS::cust_pkg ( \%hash );
     $error = $new->replace($self);
     if ( $error ) {
     my $new = new FS::cust_pkg ( \%hash );
     $error = $new->replace($self);
     if ( $error ) {
@@ -593,6 +682,23 @@ sub last_bill {
   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
 }
 
   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
 }
 
+=item last_reason
+
+Returns the most recent FS::reason associated with the package.
+
+=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',
+                                 } );
+  qsearchs ( 'reason', { 'reasonnum' => $cust_pkg_reason->reasonnum } )
+    if $cust_pkg_reason;
+}
+
 =item part_pkg
 
 Returns the definition for this billing item, as an FS::part_pkg object (see
 =item part_pkg
 
 Returns the definition for this billing item, as an FS::part_pkg object (see
@@ -778,26 +884,45 @@ Returns a short status string for this package, currently:
 sub status {
   my $self = shift;
 
 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 'cancelled' if $self->get('cancel');
   return 'suspended' if $self->susp;
   return 'not yet billed' unless $self->setup;
-  return 'one-time charge' if $self->part_pkg->freq =~ /^(0|$)/;
+  return 'one-time charge' if $freq =~ /^(0|$)/;
   return 'active';
 }
 
   return 'active';
 }
 
-=item statuscolor
+=item statuses
 
 
-Returns a hex triplet color string for this package's status.
+Class method that returns the list of possible status strings for pacakges
+(see L<the status method|/status>).  For example:
+
+  @statuses = FS::cust_pkg->statuses();
 
 =cut
 
 
 =cut
 
-my %statuscolor = (
+tie my %statuscolor, 'Tie::IxHash', 
   'not yet billed'  => '000000',
   'one-time charge' => '000000',
   'active'          => '00CC00',
   'suspended'       => 'FF9900',
   'cancelled'       => 'FF0000',
   'not yet billed'  => '000000',
   '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};
 sub statuscolor {
   my $self = shift;
   $statuscolor{$self->status};
@@ -1117,7 +1242,7 @@ sub reexport {
 
 =back
 
 
 =back
 
-=head1 CLASS METHOD
+=head1 CLASS METHODS
 
 =over 4
 
 
 =over 4
 
@@ -1132,6 +1257,17 @@ sub recurring_sql { "
              where cust_pkg.pkgpart = part_pkg.pkgpart )
 "; }
 
              where cust_pkg.pkgpart = part_pkg.pkgpart )
 "; }
 
+=item onetime_sql
+
+Returns an SQL expression identifying one-time packages.
+
+=cut
+
+sub onetime_sql { "
+  '0' = ( select freq from part_pkg
+            where cust_pkg.pkgpart = part_pkg.pkgpart )
+"; }
+
 =item active_sql
 
 Returns an SQL expression identifying active packages.
 =item active_sql
 
 Returns an SQL expression identifying active packages.
@@ -1144,6 +1280,19 @@ sub active_sql { "
   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
 "; }
 
   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
 "; }
 
+=item inactive_sql
+
+Returns an SQL expression identifying inactive packages (one-time packages
+that are otherwise unsuspended/uncancelled).
+
+=cut
+
+sub inactive_sql { "
+  ". $_[0]->onetime_sql(). "
+  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+  AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
+"; }
+
 =item susp_sql
 =item suspended_sql
 
 =item susp_sql
 =item suspended_sql
 
@@ -1152,11 +1301,13 @@ Returns an SQL expression identifying suspended packages.
 =cut
 
 sub suspended_sql { susp_sql(@_); }
 =cut
 
 sub suspended_sql { susp_sql(@_); }
-sub susp_sql { "
-  ". $_[0]->recurring_sql(). "
-  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-  AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
-"; }
+sub susp_sql {
+  #$_[0]->recurring_sql(). ' AND '.
+  "
+        ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
+    AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
+  ";
+}
 
 =item cancel_sql
 =item cancelled_sql
 
 =item cancel_sql
 =item cancelled_sql
@@ -1166,10 +1317,10 @@ Returns an SQL exprression identifying cancelled packages.
 =cut
 
 sub cancelled_sql { cancel_sql(@_); }
 =cut
 
 sub cancelled_sql { cancel_sql(@_); }
-sub cancel_sql { "
-  ". $_[0]->recurring_sql(). "
-  AND cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0
-"; }
+sub cancel_sql { 
+  #$_[0]->recurring_sql(). ' AND '.
+  "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
+}
 
 =head1 SUBROUTINES
 
 
 =head1 SUBROUTINES
 
@@ -1273,7 +1424,7 @@ sub order {
       $dbh->rollback if $oldAutoCommit;
       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
     }
       $dbh->rollback if $oldAutoCommit;
       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
     }
-    $error = $old_pkg->cancel;
+    $error = $old_pkg->cancel( quiet=>1 );
     if ($error) {
       $dbh->rollback;
       return $error;
     if ($error) {
       $dbh->rollback;
       return $error;
@@ -1283,6 +1434,44 @@ sub order {
   '';
 }
 
   '';
 }
 
+sub insert_reason {
+  my ($self, %options) = @_;
+
+  my $otaker = $FS::CurrentUser::CurrentUser->name;
+  $otaker = $FS::CurrentUser::CurrentUser->username
+    if (($otaker) eq "User, Legacy");
+
+  my $cust_pkg_reason =
+    new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
+                              'reasonnum' => $options{'reason'}, 
+                             'otaker'    => $otaker,
+                             'date'      => $options{'date'}
+                                              ? $options{'date'}
+                                              : time,
+                           });
+  return $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");
+  }
+}
+
 =back
 
 =head1 BUGS
 =back
 
 =head1 BUGS