email_search_result for cust_pkg and svc_broadband, RT#8736
[freeside.git] / FS / FS / cust_main.pm
index 6d32e00..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
@@ -31,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;
@@ -1301,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.
@@ -1311,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';
@@ -1335,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 ( $self->cust_credit ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with credits";
+  if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't delete a master agent customer";
   }
-  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";
@@ -1381,8 +1409,15 @@ sub delete {
     }
   }
 
-  foreach my $table (qw( cust_main_invoice cust_main_exemption cust_tag )) {
-    foreach my $record ( qsearch( 'table', { 'custnum' => $self->custnum } ) ) {
+  #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;
@@ -1391,6 +1426,54 @@ sub delete {
     }
   }
 
+  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 $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;
@@ -1712,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'   => '',
@@ -1725,7 +1809,7 @@ sub check {
           'country' => $self->country,
         } );
     }
-#  }
+  }
 
   $error =
     $self->ut_phonen('daytime', $self->country)
@@ -1994,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;
@@ -2901,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 );
 
@@ -2936,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
 
@@ -2955,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 =
@@ -2976,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);
+
       }
 
     }
@@ -3075,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.
@@ -3358,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';
@@ -3393,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?
@@ -3417,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;
@@ -3584,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 };
@@ -3615,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);
@@ -4805,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;
@@ -5111,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;
@@ -6261,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
@@ -7086,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
@@ -7096,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
@@ -7477,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
@@ -7491,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
@@ -7498,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
@@ -7513,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
@@ -7865,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;
@@ -8000,160 +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 $error = '';
-
-  my $job = delete $params->{'job'}
-    or die "email_search_result must run from the job queue.\n";
-
-  $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
-  my @retry_jobs = ();
-  my $success = 0;
-
-  #eventually order+limit magic to reduce memory use?
-  foreach my $cust_main ( qsearch($sql_query) ) {
-
-    #progressbar first, so that the count is right
-    $num++;
-    if ( time - $min_sec > $last ) {
-      my $error = $job->update_statustext(
-        int( 100 * $num / $num_cust )
-      );
-      die $error if $error;
-      $last = time;
-    }
-
-    my $to = $cust_main->invoicing_list_emailonly_scalar;
-
-    if( $to ) {
-      my @message = (
-        'from'      => $from,
-        'to'        => $to,
-        'subject'   => $subject,
-        'html_body' => $html_body,
-        'text_body' => $text_body,
-      );
-
-      $error = send_email( generate_email( @message ) );
-
-      if($error) {
-        # queue the sending of this message so that the user can see what we 
-        # tried to do, and retry if desired
-        my $queue = new FS::queue {
-          'job'        => 'FS::Misc::process_send_email',
-          'custnum'    => $cust_main->custnum,
-          'status'     => 'failed',
-          'statustext' => $error,
-        };
-        $queue->insert(@message);
-        push @retry_jobs, $queue;
-      }
-      else {
-        $success++;
-      }
-    }
-
-    if($success == 0 and 
-        (scalar(@retry_jobs) > 10 or $num == $num_cust)
-      ) {
-      # 10 is arbitrary, but if we have enough failures, that's 
-      # probably a configuration or network problem, and we 
-      # abort the batch and run away screaming.
-      # We NEVER do this if anything was successfully sent.
-      $_->delete foreach (@retry_jobs);
-      return "multiple failures: '$error'\n";
-    }
-  }
-
-  if(@retry_jobs) {
-    # fail the job, but with a status message that makes it clear
-    # something was sent.
-    return "Sent $success, failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
-  }
-
-  return '';
-}
-
-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
@@ -8682,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 $@;
@@ -8722,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 ) {
@@ -8759,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
@@ -8872,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) = @_;
 
@@ -8922,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} = '~';
@@ -8935,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',
@@ -8944,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 
@@ -8955,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