add logo_file support to welcome_letter and fix leaving temp files around for invoice...
[freeside.git] / FS / FS / cust_main.pm
index 684031c..e107e6c 100644 (file)
@@ -16,6 +16,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);
@@ -55,6 +57,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 +65,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;
@@ -86,7 +90,7 @@ $skip_fuzzyfiles = 0;
 @fuzzyfields = ( 'first', 'last', 'company', 'address1' );
 
 @encrypted_fields = ('payinfo', 'paycvv');
-sub nohistory_fields { ('paycvv'); }
+sub nohistory_fields { ('payinfo', 'paycvv'); }
 
 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
 
@@ -470,6 +474,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 +574,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;
 
@@ -1314,23 +1381,13 @@ 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;
-    }
-  }
-
-  foreach my $cust_main_exemption (
-    qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } )
-  ) {
-    my $error = $cust_main_exemption->delete;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+  foreach my $table (qw( cust_main_invoice cust_main_exemption cust_tag )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
     }
   }
 
@@ -1340,6 +1397,23 @@ sub delete {
     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 +1493,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'};
@@ -1453,8 +1549,15 @@ sub replace {
 
   }
 
-  if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
-       grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
+  if ( $self->payby =~ /^(CARD|CHEK|LECB)$/
+       && ( ( $self->get('payinfo') ne $old->get('payinfo')
+              && $self->get('payinfo') !~ /^99\d{14}$/ 
+            )
+            || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
+          )
+     )
+  {
+
     # card/check/lec info has changed, want to retry realtime_ invoice events
     my $error = $self->retry_realtime;
     if ( $error ) {
@@ -1471,6 +1574,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;
   '';
 
@@ -1708,12 +1828,7 @@ sub check {
 
   # If it is encrypted and the private key is not availaible then we can't
   # check the credit card.
-
-  my $check_payinfo = 1;
-
-  if ($self->is_encrypted($self->payinfo)) {
-    $check_payinfo = 0;
-  }
+  my $check_payinfo = ! $self->is_encrypted($self->payinfo);
 
   if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
@@ -1727,7 +1842,8 @@ sub check {
       or return gettext('invalid_card'); # . ": ". $self->payinfo;
 
     return gettext('unknown_card_type')
-      if cardtype($self->payinfo) eq "Unknown";
+      if $self->payinfo !~ /^99\d{14}$/ #token
+      && cardtype($self->payinfo) eq "Unknown";
 
     my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
     if ( $ban ) {
@@ -2104,6 +2220,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;
   }
 
@@ -2382,6 +2501,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
@@ -2472,6 +2627,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
@@ -2488,7 +2647,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";
@@ -2505,6 +2666,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";
@@ -2513,6 +2675,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";
@@ -2521,6 +2684,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
   ) {
@@ -2532,6 +2696,7 @@ sub bill_and_collect {
       else                                                   { warn   $error; }
     }
   }
+  $job->update_statustext('100,finished') if $job;
 
   '';
 
@@ -2681,8 +2846,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,
@@ -2694,6 +2865,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' );
 
@@ -3619,19 +3793,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;
-  '';
 
 }
 
@@ -3726,6 +3898,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
@@ -3734,11 +3911,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;
     }
 
@@ -3747,20 +3920,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;
   '';
 
 }
@@ -4239,6 +4408,7 @@ sub _bop_options {
   $options->{payment_gateway}->gatewaynum
     ? $options->{payment_gateway}->options
     : @{ $options->{payment_gateway}->get('options') };
+
 }
 
 sub _bop_defaults {
@@ -4265,14 +4435,6 @@ sub _bop_content {
   my ($self, $options) = @_;
   my %content = ();
 
-  $content{address} = exists($options->{'address1'})
-                        ? $options->{'address1'}
-                        : $self->address1;
-  my $address2 = exists($options->{'address2'})
-                   ? $options->{'address2'}
-                   : $self->address2;
-  $content{address} .= ", ". $address2 if length($address2);
-
   my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
   $content{customer_ip} = $payip if length($payip);
 
@@ -4283,14 +4445,30 @@ sub _bop_content {
     (    $conf->exists('business-onlinepayment-email_customer')
       || $conf->exists('business-onlinepayment-email-override') );
       
-  $content{payfirst} = $self->getfield('first');
-  $content{paylast} = $self->getfield('last');
+  my ($payname, $payfirst, $paylast);
+  if ( $options->{payname} && $options->{method} ne 'ECHECK' ) {
+    ($payname = $options->{payname}) =~
+      /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
+      or return "Illegal payname $payname";
+    ($payfirst, $paylast) = ($1, $2);
+  } else {
+    $payfirst = $self->getfield('first');
+    $paylast = $self->getfield('last');
+    $payname = "$payfirst $paylast";
+  }
+
+  $content{last_name} = $paylast;
+  $content{first_name} = $payfirst;
 
-  $content{account_name} = "$content{payfirst} $content{paylast}"
-    if $options->{method} eq 'ECHECK';
+  $content{name} = $payname;
 
-  $content{name} = $options->{payname};
-  $content{name} = $content{account_name} if exists($content{account_name});
+  $content{address} = exists($options->{'address1'})
+                        ? $options->{'address1'}
+                        : $self->address1;
+  my $address2 = exists($options->{'address2'})
+                   ? $options->{'address2'}
+                   : $self->address2;
+  $content{address} .= ", ". $address2 if length($address2);
 
   $content{city} = exists($options->{city})
                      ? $options->{city}
@@ -4304,10 +4482,11 @@ sub _bop_content {
   $content{country} = exists($options->{country})
                         ? $options->{country}
                         : $self->country;
+
   $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
   $content{phone} = $self->daytime || $self->night;
 
-  (%content);
+  \%content;
 }
 
 my %bop_method2payby = (
@@ -4383,13 +4562,8 @@ sub realtime_bop {
   # massage data
   ###
 
-  my (%bop_content) = $self->_bop_content(\%options);
-
-  if ( $options{method} ne 'ECHECK' ) {
-    $options{payname} =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
-      or return "Illegal payname $options{payname}";
-    ($bop_content{payfirst}, $bop_content{paylast}) = ($1, $2);
-  }
+  my $bop_content = $self->_bop_content(\%options);
+  return $bop_content unless ref($bop_content);
 
   my @invoicing_list = $self->invoicing_list_emailonly;
   if ( $conf->exists('emailinvoiceautoalways')
@@ -4455,6 +4629,9 @@ sub realtime_bop {
     $content{account_type} = exists($options{'paytype'})
                                ? uc($options{'paytype'}) || 'CHECKING'
                                : uc($self->getfield('paytype')) || 'CHECKING';
+    $content{account_name} = $self->getfield('first'). ' '.
+                             $self->getfield('last');
+
     $content{customer_org} = $self->company ? 'B' : 'I';
     $content{state_id}       = exists($options{'stateid'})
                                  ? $options{'stateid'}
@@ -4539,7 +4716,7 @@ sub realtime_bop {
     'amount'         => $options{amount},
     #'invoice_number' => $options{'invnum'},
     'customer_id'    => $self->custnum,
-    %bop_content,
+    %$bop_content,
     'reference'      => $cust_pay_pending->paypendingnum, #for now
     'email'          => $email,
     %content, #after
@@ -4554,6 +4731,8 @@ sub realtime_bop {
   my $BOP_TESTING_SUCCESS = 1;
 
   unless ( $BOP_TESTING ) {
+    $transaction->test_transaction(1)
+      if $conf->exists('business-onlinepayment-test_transaction');
     $transaction->submit();
   } else {
     if ( $BOP_TESTING_SUCCESS ) {
@@ -4606,6 +4785,8 @@ sub realtime_bop {
 
     $capture->content( %capture );
 
+    $capture->test_transaction(1)
+      if $conf->exists('business-onlinepayment-test_transaction');
     $capture->submit();
 
     unless ( $capture->is_success ) {
@@ -4635,6 +4816,25 @@ sub realtime_bop {
   }
 
   ###
+  # Tokenize
+  ###
+
+
+  if ( $transaction->can('card_token') && $transaction->card_token ) {
+
+    $self->card_token($transaction->card_token);
+
+    if ( $options{'payinfo'} eq $self->payinfo ) {
+      $self->payinfo($transaction->card_token);
+      my $error = $self->replace;
+      if ( $error ) {
+        warn "WARNING: error storing token: $error, but proceeding anyway\n";
+      }
+    }
+
+  }
+
+  ###
   # result handling
   ###
 
@@ -4757,7 +4957,7 @@ sub _realtime_bop_result {
        'paid'     => $cust_pay_pending->paid,
        '_date'    => '',
        'payby'    => $cust_pay_pending->payby,
-       #'payinfo'  => $payinfo,
+       'payinfo'  => $options{'payinfo'},
        'paybatch' => $paybatch,
        'paydate'  => $cust_pay_pending->paydate,
        'pkgnum'   => $cust_pay_pending->pkgnum,
@@ -4911,28 +5111,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;
@@ -5161,7 +5375,7 @@ sub realtime_refund_bop {
   my $self = shift;
 
   my %options = ();
-  if (ref($_[0]) ne 'HASH') {
+  if (ref($_[0]) eq 'HASH') {
     %options = %{$_[0]};
   } else {
     my $method = shift;
@@ -5293,6 +5507,8 @@ sub realtime_refund_bop {
       }
     }
     $void->content( 'action' => 'void', %content );
+    $void->test_transaction(1)
+      if $conf->exists('business-onlinepayment-test_transaction');
     $void->submit();
     if ( $void->is_success ) {
       my $error = $cust_pay->void($options{'reason'});
@@ -5395,6 +5611,8 @@ sub realtime_refund_bop {
   );
   warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
     if $DEBUG > 1;
+  $refund->test_transaction(1)
+    if $conf->exists('business-onlinepayment-test_transaction');
   $refund->submit();
 
   return "$processor error: ". $refund->error_message
@@ -5833,29 +6051,17 @@ sub total_owed_date {
   my $self = shift;
   my $time = shift;
 
-#  my $custnum = $self->custnum;
-#
-#  my $owed_sql = FS::cust_bill->owed_sql;
-#
-#  my $sql = "
-#    SELECT SUM($owed_sql) FROM cust_bill
-#      WHERE custnum = $custnum
-#        AND _date <= $time
-#  ";
-#
-#  my $sth = dbh->prepare($sql) or die dbh->errstr;
-#  $sth->execute() or die $sth->errstr;
-#
-#  return sprintf( '%.2f', $sth->fetchrow_arrayref->[0] );
+  my $custnum = $self->custnum;
 
-  my $total_bill = 0;
-  foreach my $cust_bill (
-    grep { $_->_date <= $time }
-      qsearch('cust_bill', { 'custnum' => $self->custnum, } )
-  ) {
-    $total_bill += $cust_bill->owed;
-  }
-  sprintf( "%.2f", $total_bill );
+  my $owed_sql = FS::cust_bill->owed_sql;
+
+  my $sql = "
+    SELECT SUM($owed_sql) FROM cust_bill
+      WHERE custnum = $custnum
+        AND _date <= $time
+  ";
+
+  sprintf( "%.2f", $self->scalar_sql($sql) );
 
 }
 
@@ -5925,9 +6131,18 @@ sub total_credited {
 
 sub total_unapplied_credits {
   my $self = shift;
-  my $total_credit = 0;
-  $total_credit += $_->credited foreach $self->cust_credit;
-  sprintf( "%.2f", $total_credit );
+
+  my $custnum = $self->custnum;
+
+  my $unapplied_sql = FS::cust_credit->unapplied_sql;
+
+  my $sql = "
+    SELECT SUM($unapplied_sql) FROM cust_credit
+      WHERE custnum = $custnum
+  ";
+
+  sprintf( "%.2f", $self->scalar_sql($sql) );
+
 }
 
 =item total_unapplied_credits_pkgnum PKGNUM
@@ -5954,9 +6169,18 @@ See L<FS::cust_pay/unapplied>.
 
 sub total_unapplied_payments {
   my $self = shift;
-  my $total_unapplied = 0;
-  $total_unapplied += $_->unapplied foreach $self->cust_pay;
-  sprintf( "%.2f", $total_unapplied );
+
+  my $custnum = $self->custnum;
+
+  my $unapplied_sql = FS::cust_pay->unapplied_sql;
+
+  my $sql = "
+    SELECT SUM($unapplied_sql) FROM cust_pay
+      WHERE custnum = $custnum
+  ";
+
+  sprintf( "%.2f", $self->scalar_sql($sql) );
+
 }
 
 =item total_unapplied_payments_pkgnum PKGNUM
@@ -5984,9 +6208,17 @@ customer.  See L<FS::cust_refund/unapplied>.
 
 sub total_unapplied_refunds {
   my $self = shift;
-  my $total_unapplied = 0;
-  $total_unapplied += $_->unapplied foreach $self->cust_refund;
-  sprintf( "%.2f", $total_unapplied );
+  my $custnum = $self->custnum;
+
+  my $unapplied_sql = FS::cust_refund->unapplied_sql;
+
+  my $sql = "
+    SELECT SUM($unapplied_sql) FROM cust_refund
+      WHERE custnum = $custnum
+  ";
+
+  sprintf( "%.2f", $self->scalar_sql($sql) );
+
 }
 
 =item balance
@@ -5998,12 +6230,7 @@ total_unapplied_credits minus total_unapplied_payments).
 
 sub balance {
   my $self = shift;
-  sprintf( "%.2f",
-      $self->total_owed
-    + $self->total_unapplied_refunds
-    - $self->total_unapplied_credits
-    - $self->total_unapplied_payments
-  );
+  $self->balance_date_range;
 }
 
 =item balance_date TIME
@@ -6018,19 +6245,13 @@ functions.
 
 sub balance_date {
   my $self = shift;
-  my $time = shift;
-  sprintf( "%.2f",
-        $self->total_owed_date($time)
-      + $self->total_unapplied_refunds
-      - $self->total_unapplied_credits
-      - $self->total_unapplied_payments
-  );
+  $self->balance_date_range(shift);
 }
 
-=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+=item balance_date_range [ START_TIME [ END_TIME [ OPTION => VALUE ... ] ] ]
 
-Returns the balance for this customer, only considering invoices with date
-earlier than START_TIME, and optionally not later than END_TIME
+Returns the balance for this customer, optionally considering invoices with
+date earlier than START_TIME, and not later than END_TIME
 (total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
 
 Times are specified as SQL fragments or numeric
@@ -6054,7 +6275,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
@@ -6879,6 +7100,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
@@ -6889,11 +7130,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
@@ -7094,6 +7352,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)
@@ -7110,7 +7370,8 @@ sub status { shift->cust_status(@_); }
 
 sub cust_status {
   my $self = shift;
-  for my $status (qw( prospect active inactive suspended cancelled )) {
+  # prospect ordered active inactive suspended cancelled
+  for my $status ( FS::cust_main->statuses() ) {
     my $method = $status.'_sql';
     my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
@@ -7145,6 +7406,7 @@ use vars qw(%statuscolor);
 tie %statuscolor, 'Tie::IxHash',
   'prospect'  => '7e0079', #'000000', #black?  naw, purple
   'active'    => '00CC00', #green
+  'ordered'   => '009999', #teal? cyan?
   'inactive'  => '0000CC', #blue
   'suspended' => 'FF9900', #yellow
   'cancelled' => 'FF0000', #red
@@ -7254,9 +7516,21 @@ sub select_count_pkgs_sql {
   $select_count_pkgs;
 }
 
-sub prospect_sql { "
-  0 = ( $select_count_pkgs )
-"; }
+sub prospect_sql {
+  " 0 = ( $select_count_pkgs ) ";
+}
+
+=item ordered_sql
+
+Returns an SQL expression identifying ordered cust_main records (customers with
+recurring packages not yet setup).
+
+=cut
+
+sub ordered_sql {
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) ";
+}
 
 =item active_sql
 
@@ -7265,10 +7539,21 @@ active recurring packages).
 
 =cut
 
-sub active_sql { "
-  0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
-      )
-"; }
+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
 
@@ -7277,11 +7562,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
@@ -7292,11 +7576,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
@@ -7357,10 +7640,10 @@ sub balance_sql { "
         WHERE cust_refund.custnum = cust_main.custnum     )
 "; }
 
-=item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+=item balance_date_sql [ START_TIME [ END_TIME [ OPTION => VALUE ... ] ] ]
 
-Returns an SQL fragment to retreive the balance for this customer, only
-considering invoices with date earlier than START_TIME, and optionally not
+Returns an SQL fragment to retreive the balance for this customer, optionally
+considering invoices with date earlier than START_TIME, and not
 later than END_TIME (total_owed_date minus total_unapplied_credits minus
 total_unapplied_payments).
 
@@ -7392,6 +7675,12 @@ WHERE clause hashref (elements "AND"ed together) (typically used with the total
 (unused.  obsolete?)
 JOIN clause (typically used with the total option)
 
+=item cutoff
+
+An absolute cutoff time.  Payments, credits, and refunds I<applied> after this 
+time will be ignored.  Note that START_TIME and END_TIME only limit the date 
+range for invoices and I<unapplied> payments, credits, and refunds.
+
 =back
 
 =cut
@@ -7399,10 +7688,12 @@ JOIN clause (typically used with the total option)
 sub balance_date_sql {
   my( $class, $start, $end, %opt ) = @_;
 
-  my $owed         = FS::cust_bill->owed_sql;
-  my $unapp_refund = FS::cust_refund->unapplied_sql;
-  my $unapp_credit = FS::cust_credit->unapplied_sql;
-  my $unapp_pay    = FS::cust_pay->unapplied_sql;
+  my $cutoff = $opt{'cutoff'};
+
+  my $owed         = FS::cust_bill->owed_sql($cutoff);
+  my $unapp_refund = FS::cust_refund->unapplied_sql($cutoff);
+  my $unapp_credit = FS::cust_credit->unapplied_sql($cutoff);
+  my $unapp_pay    = FS::cust_pay->unapplied_sql($cutoff);
 
   my $j = $opt{'join'} || '';
 
@@ -7435,9 +7726,11 @@ Available options are:
 =cut
 
 sub unapplied_payments_date_sql {
-  my( $class, $start, $end, ) = @_;
+  my( $class, $start, $end, %opt ) = @_;
+
+  my $cutoff = $opt{'cutoff'};
 
-  my $unapp_pay    = FS::cust_pay->unapplied_sql;
+  my $unapp_pay    = FS::cust_pay->unapplied_sql($cutoff);
 
   my $pay_where = $class->_money_table_where( 'cust_pay', $start, $end,
                                                           'unapplied_date'=>1 );
@@ -7548,7 +7841,7 @@ sub search {
   # parse status
   ##
 
-  #prospect active inactive suspended cancelled
+  #prospect ordered active inactive suspended cancelled
   if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
     my $method = $params->{'status'}. '_sql';
     #push @where, $class->$method();
@@ -7816,8 +8109,10 @@ sub email_search_result {
   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'};
+  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'});
@@ -7837,43 +8132,73 @@ sub email_search_result {
 
 
   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;
-    next unless $to;
 
-    my $error = send_email(
-      generate_email(
+    if( $to ) {
+      my @message = (
         '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;
+      $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 '';
 }
 
-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;
@@ -8496,6 +8821,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
@@ -8609,6 +8937,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) = @_;
 
@@ -8672,6 +9001,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',
@@ -8681,7 +9021,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 
@@ -8692,8 +9033,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
@@ -8790,14 +9135,35 @@ sub _agent_plandata {
 
 }
 
+=item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
+
+Subroutine (not a method), designed to be called from the queue.
+
+Takes a list of options and values.
+
+Pulls up the customer record via the custnum option and calls bill_and_collect.
+
+=cut
+
 sub queued_bill {
-  ## actual sub, not a method, designed to be called from the queue.
-  ## sets up the customer, and calls the bill_and_collect
   my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
+
   my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
-      $cust_main->bill_and_collect(
-        %args,
-      );
+  warn 'bill_and_collect custnum#'. $cust_main->custnum. "\n";#log custnum w/pid
+
+  $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
@@ -8808,6 +9174,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);
 
 }