email_search_result for cust_pkg and svc_broadband, RT#8736
[freeside.git] / FS / FS / cust_main.pm
index c83ae95..007beec 100644 (file)
@@ -2,7 +2,11 @@ package FS::cust_main;
 
 require 5.006;
 use strict;
-use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::Record );
+use base qw( FS::otaker_Mixin
+             FS::payinfo_Mixin
+             FS::cust_main_Mixin
+             FS::Record
+            );
 use vars qw( @EXPORT_OK $DEBUG $me $conf
              @encrypted_fields
              $import $ignore_expired_card
@@ -16,6 +20,8 @@ use Exporter;
 use Scalar::Util qw( blessed );
 use List::Util qw( min );
 use Time::Local qw(timelocal);
+use Storable qw(thaw);
+use MIME::Base64;
 use Data::Dumper;
 use Tie::IxHash;
 use Digest::MD5 qw(md5_base64);
@@ -29,6 +35,7 @@ use FS::UID qw( getotaker dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Misc qw( generate_email send_email generate_ps do_print );
 use FS::Msgcat qw(gettext);
+use FS::CurrentUser;
 use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
@@ -55,6 +62,7 @@ use FS::cust_tax_location;
 use FS::part_pkg_taxrate;
 use FS::agent;
 use FS::cust_main_invoice;
+use FS::cust_tag;
 use FS::cust_credit_bill;
 use FS::cust_bill_pay;
 use FS::prepay_credit;
@@ -62,6 +70,7 @@ use FS::queue;
 use FS::part_pkg;
 use FS::part_event;
 use FS::part_event_condition;
+use FS::part_export;
 #use FS::cust_event;
 use FS::type_pkgs;
 use FS::payment_gateway;
@@ -470,6 +479,30 @@ sub insert {
     $self->invoicing_list( $invoicing_list );
   }
 
+  warn "  setting customer tags\n"
+    if $DEBUG > 1;
+
+  foreach my $tagnum ( @{ $self->tagnum || [] } ) {
+    my $cust_tag = new FS::cust_tag { 'tagnum'  => $tagnum,
+                                      'custnum' => $self->custnum };
+    my $error = $cust_tag->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  if ( $invoicing_list ) {
+    $error = $self->check_invoicing_list( $invoicing_list );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      #return "checking invoicing_list (transaction rolled back): $error";
+      return $error;
+    }
+    $self->invoicing_list( $invoicing_list );
+  }
+
+
   warn "  setting cust_main_exemption\n"
     if $DEBUG > 1;
 
@@ -546,6 +579,45 @@ sub insert {
     }
   }
 
+  # cust_main exports!
+  warn "  exporting\n" if $DEBUG > 1;
+
+  my $export_args = $options{'export_args'} || [];
+
+  my @part_export =
+    map qsearch( 'part_export', {exportnum=>$_} ),
+      $conf->config('cust_main-exports'); #, $agentnum
+
+  foreach my $part_export ( @part_export ) {
+    my $error = $part_export->export_insert($self, @$export_args);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "exporting to ". $part_export->exporttype.
+             " (transaction rolled back): $error";
+    }
+  }
+
+  #foreach my $depend_jobnum ( @$depend_jobnums ) {
+  #    warn "[$me] inserting dependancies on supplied job $depend_jobnum\n"
+  #      if $DEBUG;
+  #    foreach my $jobnum ( @jobnums ) {
+  #      my $queue = qsearchs('queue', { 'jobnum' => $jobnum } );
+  #      warn "[$me] inserting dependancy for job $jobnum on $depend_jobnum\n"
+  #        if $DEBUG;
+  #      my $error = $queue->depend_insert($depend_jobnum);
+  #      if ( $error ) {
+  #        $dbh->rollback if $oldAutoCommit;
+  #        return "error queuing job dependancy: $error";
+  #      }
+  #    }
+  #  }
+  #
+  #}
+  #
+  #if ( exists $options{'jobnums'} ) {
+  #  push @{ $options{'jobnums'} }, @jobnums;
+  #}
+
   warn "  insert complete; committing transaction\n"
     if $DEBUG > 1;
 
@@ -1234,7 +1306,7 @@ sub reexport {
 
 }
 
-=item delete NEW_CUSTNUM
+=item delete [ OPTION => VALUE ... ]
 
 This deletes the customer.  If there is an error, returns the error, otherwise
 returns false.
@@ -1244,18 +1316,20 @@ what you want when a customer cancels service; for that, cancel all of the
 customer's packages (see L</cancel>).
 
 If the customer has any uncancelled packages, you need to pass a new (valid)
-customer number for those packages to be transferred to.  Cancelled packages
-will be deleted.  Did I mention that this is NOT what you want when a customer
-cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
+customer number for those packages to be transferred to, as the "new_customer"
+option.  Cancelled packages will be deleted.  Did I mention that this is NOT
+what you want when a customer cancels service and that you really should be
+looking at L<FS::cust_pkg/cancel>?  
 
 You can't delete a customer with invoices (see L<FS::cust_bill>),
-or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
-refunds (see L<FS::cust_refund>).
+statements (see L<FS::cust_statement>), credits (see L<FS::cust_credit>),
+payments (see L<FS::cust_pay>) or refunds (see L<FS::cust_refund>), unless you
+set the "delete_financials" option to a true value.
 
 =cut
 
 sub delete {
-  my $self = shift;
+  my( $self, %opt ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -1268,26 +1342,47 @@ sub delete {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  if ( $self->cust_bill ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with invoices";
+  if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't delete a master agent customer";
   }
-  if ( $self->cust_credit ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with credits";
-  }
-  if ( $self->cust_pay ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with payments";
+
+  #use FS::access_user
+  if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't delete a master employee customer";
   }
-  if ( $self->cust_refund ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with refunds";
+
+  tie my %financial_tables, 'Tie::IxHash',
+    'cust_bill'      => 'invoices',
+    'cust_statement' => 'statements',
+    'cust_credit'    => 'credits',
+    'cust_pay'       => 'payments',
+    'cust_refund'    => 'refunds',
+  ;
+   
+  foreach my $table ( keys %financial_tables ) {
+
+    my @records = $self->$table();
+
+    if ( @records && ! $opt{'delete_financials'} ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Can't delete a customer with ". $financial_tables{$table};
+    }
+
+    foreach my $record ( @records ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error deleting ". $financial_tables{$table}. ": $error\n";
+      }
+    }
+
   }
 
   my @cust_pkg = $self->ncancelled_pkgs;
   if ( @cust_pkg ) {
-    my $new_custnum = shift;
+    my $new_custnum = $opt{'new_custnum'};
     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
       $dbh->rollback if $oldAutoCommit;
       return "Invalid new customer number: $new_custnum";
@@ -1314,32 +1409,94 @@ sub delete {
     }
   }
 
-  foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
-    qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
-  ) {
-    my $error = $cust_main_invoice->delete;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+  #cust_tax_adjustment in financials?
+  #cust_pay_pending?  ouch
+  #cust_recon?
+  foreach my $table (qw(
+    cust_main_invoice cust_main_exemption cust_tag cust_attachment contact
+    cust_location cust_main_note cust_tax_adjustment
+    cust_pay_void cust_pay_batch queue cust_tax_exempt
+  )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
     }
   }
 
-  foreach my $cust_main_exemption (
-    qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } )
-  ) {
-    my $error = $cust_main_exemption->delete;
-    if ( $error ) {
+  my $sth = $dbh->prepare(
+    'UPDATE cust_main SET referral_custnum = NULL WHERE referral_custnum = ?'
+  ) or do {
+    my $errstr = $dbh->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+  $sth->execute($self->custnum) or do {
+    my $errstr = $sth->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+
+  #tickets
+
+  my $ticket_dbh = '';
+  if ($conf->config('ticket_system') eq 'RT_Internal') {
+    $ticket_dbh = $dbh;
+  } elsif ($conf->config('ticket_system') eq 'RT_External') {
+    my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
+    $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
+      #or die "RT_External DBI->connect error: $DBI::errstr\n";
+  }
+
+  if ( $ticket_dbh ) {
+
+    my $ticket_sth = $ticket_dbh->prepare(
+      'DELETE FROM Links WHERE Target = ?'
+    ) or do {
+      my $errstr = $ticket_dbh->errstr;
       $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
+      return $errstr;
+    };
+    $ticket_sth->execute('freeside://freeside/cust_main/'.$self->custnum)
+      or do {
+        my $errstr = $ticket_sth->errstr;
+        $dbh->rollback if $oldAutoCommit;
+        return $errstr;
+      };
+
+    #check and see if the customer is the only link on the ticket, and
+    #if so, set the ticket to deleted status in RT?
+    #maybe someday, for now this will at least fix tickets not displaying
+
   }
 
+  #delete the customer record
+
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
+  # cust_main exports!
+
+  #my $export_args = $options{'export_args'} || [];
+
+  my @part_export =
+    map qsearch( 'part_export', {exportnum=>$_} ),
+      $conf->config('cust_main-exports'); #, $agentnum
+
+  foreach my $part_export ( @part_export ) {
+    my $error = $part_export->export_delete( $self ); #, @$export_args);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "exporting to ". $part_export->exporttype.
+             " (transaction rolled back): $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
@@ -1419,6 +1576,28 @@ sub replace {
     $self->invoicing_list( $invoicing_list );
   }
 
+  if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
+
+    #this could be more efficient than deleting and re-inserting, if it matters
+    foreach my $cust_tag (qsearch('cust_tag', {'custnum'=>$self->custnum} )) {
+      my $error = $cust_tag->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+    foreach my $tagnum ( @{ $self->tagnum || [] } ) {
+      my $cust_tag = new FS::cust_tag { 'tagnum'  => $tagnum,
+                                        'custnum' => $self->custnum };
+      my $error = $cust_tag->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
+
   my %options = @param;
 
   my $tax_exemption = delete $options{'tax_exemption'};
@@ -1478,6 +1657,23 @@ sub replace {
     }
   }
 
+  # cust_main exports!
+
+  my $export_args = $options{'export_args'} || [];
+
+  my @part_export =
+    map qsearch( 'part_export', {exportnum=>$_} ),
+      $conf->config('cust_main-exports'); #, $agentnum
+
+  foreach my $part_export ( @part_export ) {
+    my $error = $part_export->export_replace( $self, $old, @$export_args);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "exporting to ". $part_export->exporttype.
+             " (transaction rolled back): $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
@@ -1599,7 +1795,8 @@ sub check {
 
 
 # bad idea to disable, causes billing to fail because of no tax rates later
-#  unless ( $import ) {
+# except we don't fail any more
+  unless ( $import ) {
     unless ( qsearch('cust_main_county', {
       'country' => $self->country,
       'state'   => '',
@@ -1612,7 +1809,7 @@ sub check {
           'country' => $self->country,
         } );
     }
-#  }
+  }
 
   $error =
     $self->ut_phonen('daytime', $self->country)
@@ -1881,7 +2078,7 @@ sub check {
     $self->$flag($1);
   }
 
-  $self->otaker(getotaker) unless $self->otaker;
+  $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
   warn "$me check AFTER: \n". $self->_dump
     if $DEBUG > 2;
@@ -2107,6 +2304,9 @@ sub sort_packages {
     return 1  if !$a_num_cust_svc &&  $b_num_cust_svc;
     my @a_cust_svc = $a->cust_svc;
     my @b_cust_svc = $b->cust_svc;
+    return 0  if !scalar(@a_cust_svc) && !scalar(@b_cust_svc);
+    return -1 if  scalar(@a_cust_svc) && !scalar(@b_cust_svc);
+    return 1  if !scalar(@a_cust_svc) &&  scalar(@b_cust_svc);
     $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
   }
 
@@ -2385,6 +2585,42 @@ sub agent {
   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
 }
 
+=item agent_name
+
+Returns the agent name (see L<FS::agent>) for this customer.
+
+=cut
+
+sub agent_name {
+  my $self = shift;
+  $self->agent->agent;
+}
+
+=item cust_tag
+
+Returns any tags associated with this customer, as FS::cust_tag objects,
+or an empty list if there are no tags.
+
+=cut
+
+sub cust_tag {
+  my $self = shift;
+  qsearch('cust_tag', { 'custnum' => $self->custnum } );
+}
+
+=item part_tag
+
+Returns any tags associated with this customer, as FS::part_tag objects,
+or an empty list if there are no tags.
+
+=cut
+
+sub part_tag {
+  my $self = shift;
+  map $_->part_tag, $self->cust_tag; 
+}
+
+
 =item cust_class
 
 Returns the customer class, as an FS::cust_class object, or the empty string
@@ -2475,6 +2711,10 @@ Any other true value causes errors to die.
 
 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
 
+=item job
+
+Optional FS::queue entry to receive status updates.
+
 =back
 
 Options are passed to the B<bill> and B<collect> methods verbatim, so all
@@ -2491,7 +2731,9 @@ sub bill_and_collect {
   #pre-printing invoices
 
   $options{'actual_time'} ||= time;
+  my $job = $options{'job'};
 
+  $job->update_statustext('0,cleaning expired packages') if $job;
   $error = $self->cancel_expired_pkgs( $options{actual_time} );
   if ( $error ) {
     $error = "Error expiring custnum ". $self->custnum. ": $error";
@@ -2508,6 +2750,7 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
+  $job->update_statustext('20,billing packages') if $job;
   $error = $self->bill( %options );
   if ( $error ) {
     $error = "Error billing custnum ". $self->custnum. ": $error";
@@ -2516,6 +2759,7 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
+  $job->update_statustext('50,applying payments and credits') if $job;
   $error = $self->apply_payments_and_credits;
   if ( $error ) {
     $error = "Error applying custnum ". $self->custnum. ": $error";
@@ -2524,6 +2768,7 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
+  $job->update_statustext('70,running collection events') if $job;
   unless ( $conf->exists('cancelled_cust-noevents')
            && ! $self->num_ncancelled_pkgs
   ) {
@@ -2535,6 +2780,7 @@ sub bill_and_collect {
       else                                                   { warn   $error; }
     }
   }
+  $job->update_statustext('100,finished') if $job;
 
   '';
 
@@ -2684,8 +2930,14 @@ sub bill {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  warn "$me acquiring lock on customer ". $self->custnum. "\n"
+    if $DEBUG;
+
   $self->select_for_update; #mutex
 
+  warn "$me running pre-bill events for customer ". $self->custnum. "\n"
+    if $DEBUG;
+
   my $error = $self->do_cust_event(
     'debug'      => ( $options{'debug'} || 0 ),
     'time'       => $invoice_time,
@@ -2697,6 +2949,9 @@ sub bill {
     return $error;
   }
 
+  warn "$me done running pre-bill events for customer ". $self->custnum. "\n"
+    if $DEBUG;
+
   #keep auto-charge and non-auto-charge line items separate
   my @passes = ( '', 'no_auto' );
 
@@ -2730,7 +2985,13 @@ sub bill {
     my $real_pkgpart = $cust_pkg->pkgpart;
     my %hash = $cust_pkg->hash;
 
-    foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) {
+    # we could implement this bit as FS::part_pkg::has_hidden, but we already
+    # suffer from performance issues
+    $options{has_hidden} = 0;
+    my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked;
+    $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
+    foreach my $part_pkg ( @part_pkg ) {
 
       $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
 
@@ -2765,7 +3026,7 @@ sub bill {
 
   foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
 
-    my @cust_bill_pkg = @{ $cust_bill_pkg{$pass} };
+    my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} });
 
     next unless @cust_bill_pkg; #don't create an invoice w/o line items
 
@@ -2784,7 +3045,13 @@ sub bill {
       } elsif ( $postal_pkg ) {
 
         my $real_pkgpart = $postal_pkg->pkgpart;
-        foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
+        # we could implement this bit as FS::part_pkg::has_hidden, but we already
+        # suffer from performance issues
+        $options{has_hidden} = 0;
+        my @part_pkg = $postal_pkg->part_pkg->self_and_bill_linked;
+        $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
+
+        foreach my $part_pkg ( @part_pkg ) {
           my %postal_options = %options;
           delete $postal_options{cancel};
           my $error =
@@ -2805,6 +3072,9 @@ sub bill {
           }
         }
 
+        # it's silly to have a zero value postal_pkg, but....
+        @cust_bill_pkg = _omit_zero_value_bundles(@cust_bill_pkg);
+
       }
 
     }
@@ -2904,6 +3174,28 @@ sub bill {
   ''; #no error
 }
 
+#discard bundled packages of 0 value
+sub _omit_zero_value_bundles {
+
+  my @cust_bill_pkg = ();
+  my @cust_bill_pkg_bundle = ();
+  my $sum = 0;
+
+  foreach my $cust_bill_pkg ( @_ ) {
+    if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) {
+      push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
+      @cust_bill_pkg_bundle = ();
+      $sum = 0;
+    }
+    $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur;
+    push @cust_bill_pkg_bundle, $cust_bill_pkg;
+  }
+  push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
+
+  (@cust_bill_pkg);
+
+}
+
 =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
 
 This is a weird one.  Perhaps it should not even be exposed.
@@ -3187,6 +3479,7 @@ sub _make_lines {
     my %param = ( 'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
                   'discounts'           => \@discounts,
+                  'real_pkgpart'        => $real_pkgpart,
                 );
 
     my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
@@ -3222,7 +3515,7 @@ sub _make_lines {
   # If $cust_pkg has been modified, update it (if we're a real pkgpart)
   ###
 
-  if ( $lineitems ) {
+  if ( $lineitems || $options{has_hidden} ) {
 
     if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
       # hmm.. and if just the options are modified in some weird price plan?
@@ -3246,7 +3539,10 @@ sub _make_lines {
       return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
     }
 
-    if ( $setup != 0 || $recur != 0 ) {
+    if ( $setup != 0 ||
+         $recur != 0 ||
+         !$part_pkg->hidden && $options{has_hidden} ) #include some $0 lines
+    {
 
       warn "    charges (setup=$setup, recur=$recur); adding line items\n"
         if $DEBUG > 1;
@@ -3413,16 +3709,15 @@ sub _handle_taxes {
  
   my @display = ();
   my $separate = $conf->exists('separate_usage');
-  my $usage_mandate = $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
-  if ( $separate || $cust_bill_pkg->hidden || $usage_mandate ) {
+  my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
+  my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!');
+  my $section = $temp_pkg->part_pkg->categoryname;
+  if ( $separate || $section || $usage_mandate ) {
 
-    my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
-    my %hash = $cust_bill_pkg->hidden  # maybe for all bill linked?
-               ? (  'section' => $temp_pkg->part_pkg->categoryname )
-               : ();
+    my %hash = ( 'section' => $section );
 
-    my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
-    my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
+    $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!');
+    my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!');
     if ( $separate ) {
       push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
       push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
@@ -3444,8 +3739,10 @@ sub _handle_taxes {
       $hash{post_total} = 'Y';
     }
 
-    $hash{section} = $section if ($separate || $usage_mandate);
-    push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
+    if ($separate || $usage_mandate) {
+      $hash{section} = $section if ($separate || $usage_mandate);
+      push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
+    }
 
   }
   $cust_bill_pkg->set('display', \@display);
@@ -3622,19 +3919,17 @@ sub collect {
     }
   }
 
-  my $error = $self->do_cust_event(
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  #never want to roll back an event just because it returned an error
+  local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+
+  $self->do_cust_event(
     'debug'      => ( $options{'debug'} || 0 ),
     'time'       => $invoice_time,
     'check_freq' => $options{'check_freq'},
     'stage'      => 'collect',
   );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
 
 }
 
@@ -3729,6 +4024,11 @@ sub do_cust_event {
     return $due_cust_event;
   }
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  #never want to roll back an event just because it or a different one
+  # returned an error
+  local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+
   foreach my $cust_event ( @$due_cust_event ) {
 
     #XXX lock event
@@ -3737,11 +4037,7 @@ sub do_cust_event {
     unless ( $cust_event->test_conditions( 'time' => $time ) ) {
       #don't leave stray "new/locked" records around
       my $error = $cust_event->delete;
-      if ( $error ) {
-        #gah, even with transactions
-        $dbh->commit if $oldAutoCommit; #well.
-        return $error;
-      }
+      return $error if $error;
       next;
     }
 
@@ -3750,20 +4046,16 @@ sub do_cust_event {
       warn "  running cust_event ". $cust_event->eventnum. "\n"
         if $DEBUG > 1;
 
-      
       #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
       if ( my $error = $cust_event->do_event() ) {
         #XXX wtf is this?  figure out a proper dealio with return value
         #from do_event
-         # gah, even with transactions.
-         $dbh->commit if $oldAutoCommit; #well.
-         return $error;
-       }
+        return $error;
+      }
     }
 
   }
 
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
 }
@@ -4639,8 +4931,7 @@ sub realtime_bop {
 
   #false laziness w/misc/process/payment.cgi - check both to make sure working
   # correctly
-  if ( defined $self->dbdef_table->column('paycvv')
-       && length($self->paycvv)
+  if ( length($self->paycvv)
        && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
   ) {
     my $error = $self->remove_cvv;
@@ -4945,28 +5236,42 @@ sub _realtime_bop_result {
          && ! grep { $transaction->error_message =~ /$_/ }
                    $conf->config('emaildecline-exclude')
     ) {
-      my @templ = $conf->config('declinetemplate');
-      my $template = new Text::Template (
-        TYPE   => 'ARRAY',
-        SOURCE => [ map "$_\n", @templ ],
-      ) or return "($perror) can't create template: $Text::Template::ERROR";
-      $template->compile()
-        or return "($perror) can't compile template: $Text::Template::ERROR";
-
-      my $templ_hash = {
-        'company_name'    =>
-          scalar( $conf->config('company_name', $self->agentnum ) ),
-        'company_address' =>
-          join("\n", $conf->config('company_address', $self->agentnum ) ),
-        'error'           => $transaction->error_message,
-      };
 
-      my $error = send_email(
-        'from'    => $conf->config('invoice_from', $self->agentnum ),
-        'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
-        'subject' => 'Your payment could not be processed',
-        'body'    => [ $template->fill_in(HASH => $templ_hash) ],
-      );
+      # Send a decline alert to the customer.
+      my $msgnum = $conf->config('decline_msgnum', $self->agentnum);
+      my $error = '';
+      if ( $msgnum ) {
+        # include the raw error message in the transaction state
+        $cust_pay_pending->setfield('error', $transaction->error_message);
+        my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
+        $error = $msg_template->send( 'cust_main' => $self,
+                                      'object'    => $cust_pay_pending );
+      }
+      else { #!$msgnum
+
+        my @templ = $conf->config('declinetemplate');
+        my $template = new Text::Template (
+          TYPE   => 'ARRAY',
+          SOURCE => [ map "$_\n", @templ ],
+        ) or return "($perror) can't create template: $Text::Template::ERROR";
+        $template->compile()
+          or return "($perror) can't compile template: $Text::Template::ERROR";
+
+        my $templ_hash = {
+          'company_name'    =>
+            scalar( $conf->config('company_name', $self->agentnum ) ),
+          'company_address' =>
+            join("\n", $conf->config('company_address', $self->agentnum ) ),
+          'error'           => $transaction->error_message,
+        };
+
+        my $error = send_email(
+          'from'    => $conf->config('invoice_from', $self->agentnum ),
+          'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
+          'subject' => 'Your payment could not be processed',
+          'body'    => [ $template->fill_in(HASH => $templ_hash) ],
+        );
+      }
 
       $perror .= " (also received error sending decline notification: $error)"
         if $error;
@@ -6095,7 +6400,7 @@ sub balance_date_range {
   my $self = shift;
   my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
             ') FROM cust_main WHERE custnum='. $self->custnum;
-  sprintf( "%.2f", $self->scalar_sql($sql) );
+  sprintf( '%.2f', $self->scalar_sql($sql) );
 }
 
 =item balance_pkgnum PKGNUM
@@ -6920,6 +7225,26 @@ sub cust_pay_pending {
            );
 }
 
+=item cust_pay_pending_attempt
+
+Returns all payment attempts / declined payments for this customer, as pending
+payments objects (see L<FS::cust_pay_pending>), with status "done" but without
+a corresponding payment (see L<FS::cust_pay>).
+
+=cut
+
+sub cust_pay_pending_attempt {
+  my $self = shift;
+  return $self->num_cust_pay_pending_attempt unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay_pending', {
+                                   'custnum' => $self->custnum,
+                                   'status'  => 'done',
+                                   'paynum'  => '',
+                                 },
+           );
+}
+
 =item num_cust_pay_pending
 
 Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
@@ -6930,11 +7255,28 @@ cust_pay_pending method is used in a scalar context.
 
 sub num_cust_pay_pending {
   my $self = shift;
-  my $sql = " SELECT COUNT(*) FROM cust_pay_pending ".
-            "   WHERE custnum = ? AND status != 'done' ";
-  my $sth = dbh->prepare($sql) or die dbh->errstr;
-  $sth->execute($self->custnum) or die $sth->errstr;
-  $sth->fetchrow_arrayref->[0];
+  $self->scalar_sql(
+    " SELECT COUNT(*) FROM cust_pay_pending ".
+      " WHERE custnum = ? AND status != 'done' ",
+    $self->custnum
+  );
+}
+
+=item num_cust_pay_pending_attempt
+
+Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
+customer, with status "done" but without a corresp.  Also called automatically when the
+cust_pay_pending method is used in a scalar context.
+
+=cut
+
+sub num_cust_pay_pending_attempt {
+  my $self = shift;
+  $self->scalar_sql(
+    " SELECT COUNT(*) FROM cust_pay_pending ".
+      " WHERE custnum = ? AND status = 'done' AND paynum IS NULL",
+    $self->custnum
+  );
 }
 
 =item cust_refund
@@ -7135,6 +7477,8 @@ Returns a status string for this customer, currently:
 
 =item prospect - No packages have ever been ordered
 
+=item ordered - Recurring packages all are new (not yet billed).
+
 =item active - One or more recurring packages is active
 
 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
@@ -7186,8 +7530,8 @@ Returns a hex triplet color string for this customer's status.
 use vars qw(%statuscolor);
 tie %statuscolor, 'Tie::IxHash',
   'prospect'  => '7e0079', #'000000', #black?  naw, purple
-  'ordered'   => '009999', #teal? cyan?
   'active'    => '00CC00', #green
+  'ordered'   => '009999', #teal? cyan?
   'inactive'  => '0000CC', #blue
   'suspended' => 'FF9900', #yellow
   'cancelled' => 'FF0000', #red
@@ -7309,7 +7653,8 @@ recurring packages not yet setup).
 =cut
 
 sub ordered_sql {
-  " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) ";
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) ";
 }
 
 =item active_sql
@@ -7323,6 +7668,18 @@ sub active_sql {
   " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
 }
 
+=item none_active_sql
+
+Returns an SQL expression identifying cust_main records with no active
+recurring packages.  This includes customers of status prospect, ordered,
+inactive, and suspended.
+
+=cut
+
+sub none_active_sql {
+  " 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
+}
+
 =item inactive_sql
 
 Returns an SQL expression identifying inactive cust_main records (customers with
@@ -7330,11 +7687,10 @@ no active recurring packages, but otherwise unsuspended/uncancelled).
 
 =cut
 
-sub inactive_sql { "
-  0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
-  AND
-  0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
-"; }
+sub inactive_sql {
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) ";
+}
 
 =item susp_sql
 =item suspended_sql
@@ -7345,11 +7701,10 @@ Returns an SQL expression identifying suspended cust_main records.
 
 
 sub suspended_sql { susp_sql(@_); }
-sub susp_sql { "
-    0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
-    AND
-    0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
-"; }
+sub susp_sql {
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " ) ";
+}
 
 =item cancel_sql
 =item cancelled_sql
@@ -7697,7 +8052,7 @@ sub search {
                   ? @{ $params->{'payby'} }
                   :  ( $params->{'payby'} );
 
-    @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
+    @payby = grep /^([A-Z]{4})$/, @payby;
 
     push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
       if @payby;
@@ -7832,128 +8187,6 @@ sub search {
 
 }
 
-=item email_search_result HASHREF
-
-(Class method)
-
-Emails a notice to the specified customers.
-
-Valid parameters are those of the L<search> method, plus the following:
-
-=over 4
-
-=item from
-
-From: address
-
-=item subject
-
-Email Subject:
-
-=item html_body
-
-HTML body
-
-=item text_body
-
-Text body
-
-=item job
-
-Optional job queue job for status updates.
-
-=back
-
-Returns an error message, or false for success.
-
-If an error occurs during any email, stops the enture send and returns that
-error.  Presumably if you're getting SMTP errors aborting is better than 
-retrying everything.
-
-=cut
-
-sub email_search_result {
-  my($class, $params) = @_;
-
-  my $from = delete $params->{from};
-  my $subject = delete $params->{subject};
-  my $html_body = delete $params->{html_body};
-  my $text_body = delete $params->{text_body};
-
-  my $job = delete $params->{'job'};
-
-  $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
-    unless ref($params->{'payby'});
-
-  my $sql_query = $class->search($params);
-
-  my $count_query   = delete($sql_query->{'count_query'});
-  my $count_sth = dbh->prepare($count_query)
-    or die "Error preparing $count_query: ". dbh->errstr;
-  $count_sth->execute
-    or die "Error executing $count_query: ". $count_sth->errstr;
-  my $count_arrayref = $count_sth->fetchrow_arrayref;
-  my $num_cust = $count_arrayref->[0];
-
-  #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
-  #my @extra_fields  = @{ delete($sql_query->{'extra_fields'})  };
-
-
-  my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
-
-  #eventually order+limit magic to reduce memory use?
-  foreach my $cust_main ( qsearch($sql_query) ) {
-
-    my $to = $cust_main->invoicing_list_emailonly_scalar;
-    next unless $to;
-
-    my $error = send_email(
-      generate_email(
-        'from'      => $from,
-        'to'        => $to,
-        'subject'   => $subject,
-        'html_body' => $html_body,
-        'text_body' => $text_body,
-      )
-    );
-    return $error if $error;
-
-    if ( $job ) { #progressbar foo
-      $num++;
-      if ( time - $min_sec > $last ) {
-        my $error = $job->update_statustext(
-          int( 100 * $num / $num_cust )
-        );
-        die $error if $error;
-        $last = time;
-      }
-    }
-
-  }
-
-  return '';
-}
-
-use Storable qw(thaw);
-use Data::Dumper;
-use MIME::Base64;
-sub process_email_search_result {
-  my $job = shift;
-  #warn "$me process_re_X $method for job $job\n" if $DEBUG;
-
-  my $param = thaw(decode_base64(shift));
-  warn Dumper($param) if $DEBUG;
-
-  $param->{'job'} = $job;
-
-  $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
-    unless ref($param->{'payby'});
-
-  my $error = FS::cust_main->email_search_result( $param );
-  die $error if $error;
-
-}
-
 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
@@ -8482,7 +8715,17 @@ sub batch_charge {
   my $param = shift;
   #warn join('-',keys %$param);
   my $fh = $param->{filehandle};
-  my @fields = @{$param->{fields}};
+  my $agentnum = $param->{agentnum};
+  my $format = $param->{format};
+
+  my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+  my @fields;
+  if ( $format eq 'simple' ) {
+    @fields = qw( custnum agent_custid amount pkg );
+  } else {
+    die "unknown format $format";
+  }
 
   eval "use Text::CSV_XS;";
   die $@ if $@;
@@ -8522,10 +8765,32 @@ sub batch_charge {
       $row{$field} = shift @columns;
     }
 
-    my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
+    if ( $row{custnum} && $row{agent_custid} ) {
+      dbh->rollback if $oldAutoCommit;
+      return "can't specify custnum with agent_custid $row{agent_custid}";
+    }
+
+    my %hash = ();
+    if ( $row{agent_custid} && $agentnum ) {
+      %hash = ( 'agent_custid' => $row{agent_custid},
+                'agentnum'     => $agentnum,
+              );
+    }
+
+    if ( $row{custnum} ) {
+      %hash = ( 'custnum' => $row{custnum} );
+    }
+
+    unless ( scalar(keys %hash) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't find customer without custnum or agent_custid and agentnum";
+    }
+
+    my $cust_main = qsearchs('cust_main', { %hash } );
     unless ( $cust_main ) {
       $dbh->rollback if $oldAutoCommit;
-      return "unknown custnum $row{'custnum'}";
+      my $custnum = $row{custnum} || $row{agent_custid};
+      return "unknown custnum $custnum";
     }
 
     if ( $row{'amount'} > 0 ) {
@@ -8559,6 +8824,9 @@ sub batch_charge {
 
 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
+Deprecated.  Use event notification and message templates 
+(L<FS::msg_template>) instead.
+
 Sends a templated email notification to the customer (see L<Text::Template>).
 
 OPTIONS is a hash and may include
@@ -8672,6 +8940,7 @@ I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or
 
 =cut
 
+# a lot like cust_bill::print_latex
 sub generate_letter {
   my ($self, $template, %options) = @_;
 
@@ -8722,8 +8991,13 @@ sub generate_letter {
       $letter_data{returnaddress} = $retadd;
     } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
       $letter_data{returnaddress} =
-        join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
-                          $conf->config('company_address', $self->agentnum)
+        join( "\n", map { s/( {2,})/'~' x length($1)/eg;
+                          s/$/\\\\\*/;
+                          $_;
+                        }
+                    ( $conf->config('company_name', $self->agentnum),
+                      $conf->config('company_address', $self->agentnum),
+                    )
         );
     } else {
       $letter_data{returnaddress} = '~';
@@ -8735,6 +9009,17 @@ sub generate_letter {
   $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
 
   my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
+
+  my $lh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
+                           DIR      => $dir,
+                           SUFFIX   => '.eps',
+                           UNLINK   => 0,
+                         ) or die "can't open temp file: $!\n";
+  print $lh $conf->config_binary('logo.eps', $self->agentnum)
+    or die "can't write temp file: $!\n";
+  close $lh;
+  $letter_data{'logo_file'} = $lh->filename;
+
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
                            SUFFIX   => '.tex',
@@ -8744,7 +9029,8 @@ sub generate_letter {
   $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
   close $fh;
   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
-  return $1;
+  return ($1, $letter_data{'logo_file'});
+
 }
 
 =item print_ps TEMPLATE 
@@ -8755,8 +9041,12 @@ Returns an postscript letter filled in from TEMPLATE, as a scalar.
 
 sub print_ps {
   my $self = shift;
-  my $file = $self->generate_letter(@_);
-  FS::Misc::generate_ps($file);
+  my($file, $lfile) = $self->generate_letter(@_);
+  my $ps = FS::Misc::generate_ps($file);
+  unlink($file.'.tex');
+  unlink($lfile);
+
+  $ps;
 }
 
 =item print TEMPLATE
@@ -8872,6 +9162,18 @@ sub queued_bill {
   $cust_main->bill_and_collect( %args );
 }
 
+sub process_bill_and_collect {
+  my $job = shift;
+  my $param = thaw(decode_base64(shift));
+  my $cust_main = qsearchs( 'cust_main', { custnum => $param->{'custnum'} } )
+      or die "custnum '$param->{custnum}' not found!\n";
+  $param->{'job'}   = $job;
+  $param->{'fatal'} = 1; # runs from job queue, will be caught
+  $param->{'retry'} = 1;
+
+  $cust_main->bill_and_collect( %$param );
+}
+
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
@@ -8880,6 +9182,7 @@ sub _upgrade_data { #class method
   $sth->execute or die $sth->errstr;
 
   local($ignore_expired_card) = 1;
+  local($skip_fuzzyfiles) = 1;
   $class->_upgrade_otaker(%opts);
 
 }