add logo_file support to welcome_letter and fix leaving temp files around for invoice...
[freeside.git] / FS / FS / cust_main.pm
index 80db9dd..e107e6c 100644 (file)
@@ -2,7 +2,8 @@ package FS::cust_main;
 
 require 5.006;
 use strict;
-use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf
+use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::Record );
+use vars qw( @EXPORT_OK $DEBUG $me $conf
              @encrypted_fields
              $import $ignore_expired_card
              $skip_fuzzyfiles @fuzzyfields
@@ -15,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);
@@ -25,7 +28,7 @@ use String::Approx qw(amatch);
 use Business::CreditCard 0.28;
 use Locale::Country;
 use FS::UID qw( getotaker dbh driver_name );
-use FS::Record qw( qsearchs qsearch dbdef );
+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::payby;
@@ -54,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;
@@ -61,16 +65,14 @@ 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;
 use FS::agent_payment_gateway;
 use FS::banned_pay;
-use FS::payinfo_Mixin;
 use FS::TicketSystem;
 
-@ISA = qw( FS::payinfo_Mixin FS::Record );
-
 @EXPORT_OK = qw( smart_search );
 
 $realtime_bop_decline_quiet = 0;
@@ -88,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');
 
@@ -304,9 +306,9 @@ IP address from which payment information was received
 
 Tax exempt, empty or `Y'
 
-=item otaker
+=item usernum
 
-Order taker (assigned automatically, see L<FS::UID>)
+Order taker (see L<FS::access_user>)
 
 =item comments
 
@@ -472,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;
 
@@ -548,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;
 
@@ -1316,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;
+      }
     }
   }
 
@@ -1342,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;
   '';
 
@@ -1421,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'};
@@ -1455,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 ) {
@@ -1473,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;
   '';
 
@@ -1710,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)$/ ) {
 
@@ -1729,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 ) {
@@ -1912,6 +2026,25 @@ sub has_ship_address {
   scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
 }
 
+=item location_hash
+
+Returns a list of key/value pairs, with the following keys: address1, adddress2,
+city, county, state, zip, country.  The shipping address is used if present.
+
+=cut
+
+#geocode?  dependent on tax-ship_address config, not available in cust_location
+#mostly.  not yet then.
+
+sub location_hash {
+  my $self = shift;
+  my $prefix = $self->has_ship_address ? 'ship_' : '';
+
+  map { $_ => $self->get($prefix.$_) }
+      qw( address1 address2 city county state zip country geocode );
+      #fields that cust_location has
+}
+
 =item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all packages (see L<FS::cust_pkg>) for this customer.
@@ -1955,6 +2088,61 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+
+Options are
+
+=over 4
+
+=item join_string
+
+used to separate the address elements (defaults to ', ')
+
+=item escape_function
+
+a callback used for escaping the text of the address elements
+
+=back
+
+=cut
+
+# false laziness with FS::cust_location::line
+
+sub location_label {
+  my $self = shift;
+  my %opt = @_;
+
+  my $separator = $opt{join_string} || ', ';
+  my $escape = $opt{escape_function} || sub{ shift };
+  my $line = '';
+  my $cydefault = FS::conf->new->config('countrydefault') || 'US';
+  my $prefix = length($self->ship_last) ? 'ship_' : '';
+
+  my $notfirst = 0;
+  foreach (qw ( address1 address2 ) ) {
+    my $method = "$prefix$_";
+    $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
+      if $self->$method;
+    $notfirst++;
+  }
+  $notfirst = 0;
+  foreach (qw ( city county state zip ) ) {
+    my $method = "$prefix$_";
+    if ( $self->$method ) {
+      $line .= ' (' if $method eq 'county';
+      $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
+      $line .= ' )' if $method eq 'county';
+      $notfirst++;
+    }
+  }
+  $line .= $separator. &$escape(code2country($self->country))
+    if $self->country ne $cydefault;
+
+  $line;
+}
+
 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -2016,6 +2204,9 @@ sub _cust_pkg {
 # This should be generalized to use config options to determine order.
 sub sort_packages {
   
+  my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
+  return $locationsort if $locationsort;
+
   if ( $a->get('cancel') xor $b->get('cancel') ) {
     return -1 if $b->get('cancel');
     return  1 if $a->get('cancel');
@@ -2029,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;
   }
 
@@ -2307,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
@@ -2357,9 +2587,11 @@ sub classname {
 =item bill_and_collect 
 
 Cancels and suspends any packages due, generates bills, applies payments and
-cred
+credits, and applies collection events to run cards, send bills and notices,
+etc.
 
-Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
+By default, warns on errors and continues with the next operation (but see the
+"fatal" flag below).
 
 Options are passed as name-value pairs.  Currently available options are:
 
@@ -2385,10 +2617,20 @@ Used in conjunction with the I<time> option, this option specifies the date of f
 
 If set true, re-charges setup fees.
 
+=item fatal
+
+If set any errors prevent subsequent operations from continusing.  If set
+specifically to "return", returns the error (or false, if there is no error).
+Any other true value causes errors to die.
+
 =item debug
 
 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
@@ -2399,34 +2641,76 @@ options of those methods are also available.
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
+  my $error;
+
   #$options{actual_time} not $options{time} because freeside-daily -d is for
   #pre-printing invoices
-  $self->cancel_expired_pkgs(    $options{actual_time} );
-  $self->suspend_adjourned_pkgs( $options{actual_time} );
 
-  my $error = $self->bill( %options );
-  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
+  $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";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
+
+  $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+  if ( $error ) {
+    $error = "Error adjourning custnum ". $self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
+
+  $job->update_statustext('20,billing packages') if $job;
+  $error = $self->bill( %options );
+  if ( $error ) {
+    $error = "Error billing custnum ". $self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
 
-  $self->apply_payments_and_credits;
+  $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";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
 
+  $job->update_statustext('70,running collection events') if $job;
   unless ( $conf->exists('cancelled_cust-noevents')
            && ! $self->num_ncancelled_pkgs
   ) {
-
     $error = $self->collect( %options );
-    warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
-
+    if ( $error ) {
+      $error = "Error collecting custnum ". $self->custnum. ": $error";
+      if    ($options{fatal} && $options{fatal} eq 'return') { return $error; }
+      elsif ($options{fatal}                               ) { die    $error; }
+      else                                                   { warn   $error; }
+    }
   }
+  $job->update_statustext('100,finished') if $job;
+
+  '';
 
 }
 
 sub cancel_expired_pkgs {
-  my ( $self, $time ) = @_;
+  my ( $self, $time, %options ) = @_;
 
   my @cancel_pkgs = $self->ncancelled_pkgs( { 
     'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
   } );
 
+  my @errors = ();
+
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
     my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
@@ -2434,15 +2718,15 @@ sub cancel_expired_pkgs {
                                          )
                                        : ()
                                  );
-    warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
+  scalar(@errors) ? join(' / ', @errors) : '';
+
 }
 
 sub suspend_adjourned_pkgs {
-  my ( $self, $time ) = @_;
+  my ( $self, $time, %options ) = @_;
 
   my @susp_pkgs = $self->ncancelled_pkgs( {
     'extra_sql' =>
@@ -2466,6 +2750,8 @@ sub suspend_adjourned_pkgs {
          }
          @susp_pkgs;
 
+  my @errors = ();
+
   foreach my $cust_pkg ( @susp_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
       if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
@@ -2474,12 +2760,11 @@ sub suspend_adjourned_pkgs {
                                           )
                                         : ()
                                   );
-
-    warn "Error suspending package ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
+  scalar(@errors) ? join(' / ', @errors) : '';
+
 }
 
 =item bill OPTIONS
@@ -2561,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,
@@ -2574,15 +2865,24 @@ sub bill {
     return $error;
   }
 
-  my @cust_bill_pkg = ();
+  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' );
+
+  my %cust_bill_pkg = map { $_ => [] } @passes;
 
   ###
   # find the packages which are due for billing, find out how much they are
   # & generate invoice database.
   ###
 
-  my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
-  my %taxlisthash;
+  my %total_setup   = map { my $z = 0; $_ => \$z; } @passes;
+  my %total_recur   = map { my $z = 0; $_ => \$z; } @passes;
+
+  my %taxlisthash = map { $_ => {} } @passes;
+
   my @precommit_hooks = ();
 
   $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
@@ -2605,14 +2905,16 @@ sub bill {
 
       $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
 
+      my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
+
       my $error =
         $self->_make_lines( 'part_pkg'            => $part_pkg,
                             'cust_pkg'            => $cust_pkg,
                             'precommit_hooks'     => \@precommit_hooks,
-                            'line_items'          => \@cust_bill_pkg,
-                            'setup'               => \$total_setup,
-                            'recur'               => \$total_recur,
-                            'tax_matrix'          => \%taxlisthash,
+                            'line_items'          => $cust_bill_pkg{$pass},
+                            'setup'               => $total_setup{$pass},
+                            'recur'               => $total_recur{$pass},
+                            'tax_matrix'          => $taxlisthash{$pass},
                             'time'                => $time,
                             'real_pkgpart'        => $real_pkgpart,
                             'options'             => \%options,
@@ -2626,130 +2928,138 @@ sub bill {
 
   } #foreach my $cust_pkg
 
-  unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items
-    #but do commit any package date cycling that happened
-    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-    return '';
-  }
+  #if the customer isn't on an automatic payby, everything can go on a single
+  #invoice anyway?
+  #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
+    #merge everything into one list
+  #}
 
-  if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
-         !$conf->exists('postal_invoice-recurring_only')
-     )
-  {
+  foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
 
-    my $postal_pkg = $self->charge_postal_fee();
-    if ( $postal_pkg && !ref( $postal_pkg ) ) {
+    my @cust_bill_pkg = @{ $cust_bill_pkg{$pass} };
 
-      $dbh->rollback if $oldAutoCommit;
-      return "can't charge postal invoice fee for customer ".
-        $self->custnum. ": $postal_pkg";
-
-    } elsif ( $postal_pkg ) {
-
-      my $real_pkgpart = $postal_pkg->pkgpart;
-      foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
-        my %postal_options = %options;
-        delete $postal_options{cancel};
-        my $error =
-          $self->_make_lines( 'part_pkg'            => $part_pkg,
-                              'cust_pkg'            => $postal_pkg,
-                              'precommit_hooks'     => \@precommit_hooks,
-                              'line_items'          => \@cust_bill_pkg,
-                              'setup'               => \$total_setup,
-                              'recur'               => \$total_recur,
-                              'tax_matrix'          => \%taxlisthash,
-                              'time'                => $time,
-                              'real_pkgpart'        => $real_pkgpart,
-                              'options'             => \%postal_options,
-                            );
-        if ($error) {
-          $dbh->rollback if $oldAutoCommit;
-          return $error;
-        }
-      }
+    next unless @cust_bill_pkg; #don't create an invoice w/o line items
 
-    }
+    if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
+           !$conf->exists('postal_invoice-recurring_only')
+       )
+    {
 
-  }
+      my $postal_pkg = $self->charge_postal_fee();
+      if ( $postal_pkg && !ref( $postal_pkg ) ) {
 
-  my $listref_or_error =
-    $self->calculate_taxes( \@cust_bill_pkg, \%taxlisthash, $invoice_time);
+        $dbh->rollback if $oldAutoCommit;
+        return "can't charge postal invoice fee for customer ".
+          $self->custnum. ": $postal_pkg";
+
+      } elsif ( $postal_pkg ) {
+
+        my $real_pkgpart = $postal_pkg->pkgpart;
+        foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
+          my %postal_options = %options;
+          delete $postal_options{cancel};
+          my $error =
+            $self->_make_lines( 'part_pkg'            => $part_pkg,
+                                'cust_pkg'            => $postal_pkg,
+                                'precommit_hooks'     => \@precommit_hooks,
+                                'line_items'          => \@cust_bill_pkg,
+                                'setup'               => $total_setup{$pass},
+                                'recur'               => $total_recur{$pass},
+                                'tax_matrix'          => $taxlisthash{$pass},
+                                'time'                => $time,
+                                'real_pkgpart'        => $real_pkgpart,
+                                'options'             => \%postal_options,
+                              );
+          if ($error) {
+            $dbh->rollback if $oldAutoCommit;
+            return $error;
+          }
+        }
 
-  unless ( ref( $listref_or_error ) ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $listref_or_error;
-  }
+      }
 
-  foreach my $taxline ( @$listref_or_error ) {
-    $total_setup = sprintf('%.2f', $total_setup+$taxline->setup );
-    push @cust_bill_pkg, $taxline;
-  }
+    }
 
-  #add tax adjustments
-  warn "adding tax adjustments...\n" if $DEBUG > 2;
-  foreach my $cust_tax_adjustment (
-    qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
-                                     'billpkgnum' => '',
-                                   }
-           )
-  ) {
+    my $listref_or_error =
+      $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
 
-    my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
-
-    my $itemdesc = $cust_tax_adjustment->taxname;
-    $itemdesc = '' if $itemdesc eq 'Tax';
-
-    push @cust_bill_pkg, new FS::cust_bill_pkg {
-      'pkgnum'      => 0,
-      'setup'       => $tax,
-      'recur'       => 0,
-      'sdate'       => '',
-      'edate'       => '',
-      'itemdesc'    => $itemdesc,
-      'itemcomment' => $cust_tax_adjustment->comment,
-      'cust_tax_adjustment' => $cust_tax_adjustment,
-      #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
-    };
+    unless ( ref( $listref_or_error ) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $listref_or_error;
+    }
 
-  }
+    foreach my $taxline ( @$listref_or_error ) {
+      ${ $total_setup{$pass} } =
+        sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
+      push @cust_bill_pkg, $taxline;
+    }
 
-  my $charged = sprintf('%.2f', $total_setup + $total_recur );
+    #add tax adjustments
+    warn "adding tax adjustments...\n" if $DEBUG > 2;
+    foreach my $cust_tax_adjustment (
+      qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
+                                       'billpkgnum' => '',
+                                     }
+             )
+    ) {
 
-  my @cust_bill = $self->cust_bill;
-  my $balance = $self->balance;
-  my $previous_balance = scalar(@cust_bill)
-                           ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
-                           : 0;
+      my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
+
+      my $itemdesc = $cust_tax_adjustment->taxname;
+      $itemdesc = '' if $itemdesc eq 'Tax';
+
+      push @cust_bill_pkg, new FS::cust_bill_pkg {
+        'pkgnum'      => 0,
+        'setup'       => $tax,
+        'recur'       => 0,
+        'sdate'       => '',
+        'edate'       => '',
+        'itemdesc'    => $itemdesc,
+        'itemcomment' => $cust_tax_adjustment->comment,
+        'cust_tax_adjustment' => $cust_tax_adjustment,
+        #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+      };
 
-  $previous_balance += $cust_bill[$#cust_bill]->charged
-    if scalar(@cust_bill);
-  #my $balance_adjustments =
-  #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+    }
 
-  #create the new invoice
-  my $cust_bill = new FS::cust_bill ( {
-    'custnum'             => $self->custnum,
-    '_date'               => ( $invoice_time ),
-    'charged'             => $charged,
-    'billing_balance'     => $balance,
-    'previous_balance'    => $previous_balance,
-    'invoice_terms'       => $options{'invoice_terms'},
-  } );
-  $error = $cust_bill->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "can't create invoice for customer #". $self->custnum. ": $error";
-  }
+    my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
 
-  foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
-    $cust_bill_pkg->invnum($cust_bill->invnum); 
-    my $error = $cust_bill_pkg->insert;
+    my @cust_bill = $self->cust_bill;
+    my $balance = $self->balance;
+    my $previous_balance = scalar(@cust_bill)
+                             ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
+                             : 0;
+
+    $previous_balance += $cust_bill[$#cust_bill]->charged
+      if scalar(@cust_bill);
+    #my $balance_adjustments =
+    #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+
+    #create the new invoice
+    my $cust_bill = new FS::cust_bill ( {
+      'custnum'             => $self->custnum,
+      '_date'               => ( $invoice_time ),
+      'charged'             => $charged,
+      'billing_balance'     => $balance,
+      'previous_balance'    => $previous_balance,
+      'invoice_terms'       => $options{'invoice_terms'},
+    } );
+    $error = $cust_bill->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "can't create invoice line item: $error";
+      return "can't create invoice for customer #". $self->custnum. ": $error";
+    }
+
+    foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
+      $cust_bill_pkg->invnum($cust_bill->invnum); 
+      my $error = $cust_bill_pkg->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "can't create invoice line item: $error";
+      }
     }
-  }
-    
+
+  } #foreach my $pass ( keys %cust_bill_pkg )
 
   foreach my $hook ( @precommit_hooks ) { 
     eval {
@@ -2962,7 +3272,7 @@ sub _make_lines {
   my $old_cust_pkg = new FS::cust_pkg \%hash;
 
   my @details = ();
-
+  my @discounts = ();
   my $lineitems = 0;
 
   $cust_pkg->pkgpart($part_pkg->pkgpart);
@@ -3047,6 +3357,7 @@ sub _make_lines {
                               );
     my %param = ( 'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
+                  'discounts'           => \@discounts,
                 );
 
     my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
@@ -3126,6 +3437,7 @@ sub _make_lines {
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
+        'discounts' => \@discounts,
         'hidden'    => $part_pkg->hidden,
       };
 
@@ -3481,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;
-  '';
 
 }
 
@@ -3588,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
@@ -3596,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;
     }
 
@@ -3609,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;
   '';
 
 }
@@ -3823,7 +4130,7 @@ sub due_cust_event {
 
   warn "    invalid conditions not eliminated with condition_sql:\n".
        join('', map "      $_: ".$unsat{$_}."\n", keys %unsat )
-    if $DEBUG; # > 1;
+    if keys %unsat && $DEBUG; # > 1;
 
   ##
   # insert
@@ -3944,1012 +4251,9 @@ sub retry_realtime {
 
 }
 
-# some horrid false laziness here to avoid refactor fallout
-# eventually realtime realtime_bop and realtime_refund_bop should go
-# away and be replaced by _new_realtime_bop and _new_realtime_refund_bop
 
-=item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
-
-Runs a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment realtime gateway.  See
-L<http://420.am/business-onlinepayment> for supported gateways.
-
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
-
-Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
-
-The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
-I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
-if set, will override the value from the customer record.
-
-I<description> is a free-text field passed to the gateway.  It defaults to
-the value defined by the business-onlinepayment-description configuration
-option, or "Internet services" if that is unset.
-
-If an I<invnum> is specified, this payment (if successful) is applied to the
-specified invoice.  If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method or set the I<apply> option.
-
-I<apply> can be set to true to apply a resulting payment.
-
-I<quiet> can be set true to surpress email decline notices.
-
-I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
-resulting paynum, if any.
-
-I<payunique> is a unique identifier for this payment.
-
-(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
-
-=cut
-
-sub realtime_bop {
-  my $self = shift;
-
-  return $self->_new_realtime_bop(@_)
-    if $self->_new_bop_required();
-
-  my($method, $amount);
-  my %options = ();
-  if (ref($_[0]) eq 'HASH') {
-    %options = %{$_[0]};
-    $method = $options{method};
-    $amount = $options{amount};
-  } else {
-    ( $method, $amount ) = ( shift, shift );
-    %options = @_;
-  }
-  if ( $DEBUG ) {
-    warn "$me realtime_bop: $method $amount\n";
-    warn "  $_ => $options{$_}\n" foreach keys %options;
-  }
-
-  unless ( $options{'description'} ) {
-    if ( $conf->exists('business-onlinepayment-description') ) {
-      my $dtempl = $conf->config('business-onlinepayment-description');
-
-      my $agent = $self->agent->agent;
-      #$pkgs... not here
-      $options{'description'} = eval qq("$dtempl");
-    } else {
-      $options{'description'} = 'Internet services';
-    }
-  }
-
-  return $self->fake_bop($method, $amount, %options) if $options{'fake'};
-
-  eval "use Business::OnlinePayment";  
-  die $@ if $@;
-
-  my $payinfo = exists($options{'payinfo'})
-                  ? $options{'payinfo'}
-                  : $self->payinfo;
-
-  my %method2payby = (
-    'CC'     => 'CARD',
-    'ECHECK' => 'CHEK',
-    'LEC'    => 'LECB',
-  );
-
-  ###
-  # check for banned credit card/ACH
-  ###
-
-  my $ban = qsearchs('banned_pay', {
-    'payby'   => $method2payby{$method},
-    'payinfo' => md5_base64($payinfo),
-  } );
-  return "Banned credit card" if $ban;
-
-  ###
-  # set taxclass and trans_is_recur based on invnum if there is one
-  ###
-
-  my $taxclass = '';
-  my $trans_is_recur = 0;
-  if ( $options{'invnum'} ) {
-
-    my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
-    die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-
-    my @part_pkg =
-      map  { $_->part_pkg }
-      grep { $_ }
-      map  { $_->cust_pkg }
-      $cust_bill->cust_bill_pkg;
-
-    my @taxclasses = map $_->taxclass, @part_pkg;
-    $taxclass = $taxclasses[0]
-      unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
-                                                        #different taxclasses
-    $trans_is_recur = 1
-      if grep { $_->freq ne '0' } @part_pkg;
-
-  }
-
-  ###
-  # select a gateway
-  ###
-
-  #look for an agent gateway override first
-  my $cardtype;
-  if ( $method eq 'CC' ) {
-    $cardtype = cardtype($payinfo);
-  } elsif ( $method eq 'ECHECK' ) {
-    $cardtype = 'ACH';
-  } else {
-    $cardtype = $method;
-  }
-
-  my $override =
-       qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => $cardtype,
-                                           taxclass => $taxclass,       } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => '',
-                                           taxclass => $taxclass,       } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => $cardtype,
-                                           taxclass => '',              } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => '',
-                                           taxclass => '',              } );
-
-  my $payment_gateway = '';
-  my( $processor, $login, $password, $action, @bop_options );
-  if ( $override ) { #use a payment gateway override
-
-    $payment_gateway = $override->payment_gateway;
-
-    $processor   = $payment_gateway->gateway_module;
-    $login       = $payment_gateway->gateway_username;
-    $password    = $payment_gateway->gateway_password;
-    $action      = $payment_gateway->gateway_action;
-    @bop_options = $payment_gateway->options;
-
-  } else { #use the standard settings from the config
-
-    ( $processor, $login, $password, $action, @bop_options ) =
-      $self->default_payment_gateway($method);
-
-  }
-
-  ###
-  # massage data
-  ###
-
-  my $address = exists($options{'address1'})
-                    ? $options{'address1'}
-                    : $self->address1;
-  my $address2 = exists($options{'address2'})
-                    ? $options{'address2'}
-                    : $self->address2;
-  $address .= ", ". $address2 if length($address2);
-
-  my $o_payname = exists($options{'payname'})
-                    ? $options{'payname'}
-                    : $self->payname;
-  my($payname, $payfirst, $paylast);
-  if ( $o_payname && $method ne 'ECHECK' ) {
-    ($payname = $o_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";
-  }
-
-  my @invoicing_list = $self->invoicing_list_emailonly;
-  if ( $conf->exists('emailinvoiceautoalways')
-       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
-       || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
-    push @invoicing_list, $self->all_emails;
-  }
-
-  my $email = ($conf->exists('business-onlinepayment-email-override'))
-              ? $conf->config('business-onlinepayment-email-override')
-              : $invoicing_list[0];
-
-  my %content = ();
-
-  my $payip = exists($options{'payip'})
-                ? $options{'payip'}
-                : $self->payip;
-  $content{customer_ip} = $payip
-    if length($payip);
-
-  $content{invoice_number} = $options{'invnum'}
-    if exists($options{'invnum'}) && length($options{'invnum'});
-
-  $content{email_customer} = 
-    (    $conf->exists('business-onlinepayment-email_customer')
-      || $conf->exists('business-onlinepayment-email-override') );
-      
-  my $paydate = '';
-  if ( $method eq 'CC' ) { 
-
-    $content{card_number} = $payinfo;
-    $paydate = exists($options{'paydate'})
-                    ? $options{'paydate'}
-                    : $self->paydate;
-    $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-    $content{expiration} = "$2/$1";
-
-    my $paycvv = exists($options{'paycvv'})
-                   ? $options{'paycvv'}
-                   : $self->paycvv;
-    $content{cvv2} = $paycvv
-      if length($paycvv);
-
-    my $paystart_month = exists($options{'paystart_month'})
-                           ? $options{'paystart_month'}
-                           : $self->paystart_month;
-
-    my $paystart_year  = exists($options{'paystart_year'})
-                           ? $options{'paystart_year'}
-                           : $self->paystart_year;
-
-    $content{card_start} = "$paystart_month/$paystart_year"
-      if $paystart_month && $paystart_year;
-
-    my $payissue       = exists($options{'payissue'})
-                           ? $options{'payissue'}
-                           : $self->payissue;
-    $content{issue_number} = $payissue if $payissue;
-
-    if ( $self->_bop_recurring_billing( 'payinfo'        => $payinfo,
-                                        'trans_is_recur' => $trans_is_recur,
-                                      )
-       )
-    {
-      $content{recurring_billing} = 'YES';
-      $content{acct_code} = 'rebill'
-        if $conf->exists('credit_card-recurring_billing_acct_code');
-    }
-
-  } elsif ( $method eq 'ECHECK' ) {
-    ( $content{account_number}, $content{routing_code} ) =
-      split('@', $payinfo);
-    $content{bank_name} = $o_payname;
-    $content{bank_state} = exists($options{'paystate'})
-                             ? $options{'paystate'}
-                             : $self->getfield('paystate');
-    $content{account_type} = exists($options{'paytype'})
-                               ? uc($options{'paytype'}) || 'CHECKING'
-                               : uc($self->getfield('paytype')) || 'CHECKING';
-    $content{account_name} = $payname;
-    $content{customer_org} = $self->company ? 'B' : 'I';
-    $content{state_id}       = exists($options{'stateid'})
-                                 ? $options{'stateid'}
-                                 : $self->getfield('stateid');
-    $content{state_id_state} = exists($options{'stateid_state'})
-                                 ? $options{'stateid_state'}
-                                 : $self->getfield('stateid_state');
-    $content{customer_ssn} = exists($options{'ss'})
-                               ? $options{'ss'}
-                               : $self->ss;
-  } elsif ( $method eq 'LEC' ) {
-    $content{phone} = $payinfo;
-  }
-
-  ###
-  # run transaction(s)
-  ###
-
-  my $balance = exists( $options{'balance'} )
-                  ? $options{'balance'}
-                  : $self->balance;
-
-  $self->select_for_update; #mutex ... just until we get our pending record in
-
-  #the checks here are intended to catch concurrent payments
-  #double-form-submission prevention is taken care of in cust_pay_pending::check
-
-  #check the balance
-  return "The customer's balance has changed; $method transaction aborted."
-    if $self->balance < $balance;
-    #&& $self->balance < $amount; #might as well anyway?
-
-  #also check and make sure there aren't *other* pending payments for this cust
-
-  my @pending = qsearch('cust_pay_pending', {
-    'custnum' => $self->custnum,
-    'status'  => { op=>'!=', value=>'done' } 
-  });
-  return "A payment is already being processed for this customer (".
-         join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
-         "); $method transaction aborted."
-    if scalar(@pending);
-
-  #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
-
-  my $cust_pay_pending = new FS::cust_pay_pending {
-    'custnum'           => $self->custnum,
-    #'invnum'            => $options{'invnum'},
-    'paid'              => $amount,
-    '_date'             => '',
-    'payby'             => $method2payby{$method},
-    'payinfo'           => $payinfo,
-    'paydate'           => $paydate,
-    'recurring_billing' => $content{recurring_billing},
-    'pkgnum'            => $options{'pkgnum'},
-    'status'            => 'new',
-    'gatewaynum'        => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
-  };
-  $cust_pay_pending->payunique( $options{payunique} )
-    if defined($options{payunique}) && length($options{payunique});
-  my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
-  return $cpp_new_err if $cpp_new_err;
-
-  my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
-
-  my $transaction = new Business::OnlinePayment( $processor, @bop_options );
-  $transaction->content(
-    'type'           => $method,
-    'login'          => $login,
-    'password'       => $password,
-    'action'         => $action1,
-    'description'    => $options{'description'},
-    'amount'         => $amount,
-    #'invoice_number' => $options{'invnum'},
-    'customer_id'    => $self->custnum,
-    'last_name'      => $paylast,
-    'first_name'     => $payfirst,
-    'name'           => $payname,
-    'address'        => $address,
-    'city'           => ( exists($options{'city'})
-                            ? $options{'city'}
-                            : $self->city          ),
-    'state'          => ( exists($options{'state'})
-                            ? $options{'state'}
-                            : $self->state          ),
-    'zip'            => ( exists($options{'zip'})
-                            ? $options{'zip'}
-                            : $self->zip          ),
-    'country'        => ( exists($options{'country'})
-                            ? $options{'country'}
-                            : $self->country          ),
-    'referer'        => 'http://cleanwhisker.420.am/', #XXX fix referer :/
-    'email'          => $email,
-    'phone'          => $self->daytime || $self->night,
-    %content, #after
-  );
-
-  $cust_pay_pending->status('pending');
-  my $cpp_pending_err = $cust_pay_pending->replace;
-  return $cpp_pending_err if $cpp_pending_err;
-
-  #config?
-  my $BOP_TESTING = 0;
-  my $BOP_TESTING_SUCCESS = 1;
-
-  unless ( $BOP_TESTING ) {
-    $transaction->submit();
-  } else {
-    if ( $BOP_TESTING_SUCCESS ) {
-      $transaction->is_success(1);
-      $transaction->authorization('fake auth');
-    } else {
-      $transaction->is_success(0);
-      $transaction->error_message('fake failure');
-    }
-  }
-
-  if ( $transaction->is_success() && $action2 ) {
-
-    $cust_pay_pending->status('authorized');
-    my $cpp_authorized_err = $cust_pay_pending->replace;
-    return $cpp_authorized_err if $cpp_authorized_err;
-
-    my $auth = $transaction->authorization;
-    my $ordernum = $transaction->can('order_number')
-                   ? $transaction->order_number
-                   : '';
-
-    my $capture =
-      new Business::OnlinePayment( $processor, @bop_options );
-
-    my %capture = (
-      %content,
-      type           => $method,
-      action         => $action2,
-      login          => $login,
-      password       => $password,
-      order_number   => $ordernum,
-      amount         => $amount,
-      authorization  => $auth,
-      description    => $options{'description'},
-    );
-
-    foreach my $field (qw( authorization_source_code returned_ACI
-                           transaction_identifier validation_code           
-                           transaction_sequence_num local_transaction_date    
-                           local_transaction_time AVS_result_code          )) {
-      $capture{$field} = $transaction->$field() if $transaction->can($field);
-    }
-
-    $capture->content( %capture );
-
-    $capture->submit();
-
-    unless ( $capture->is_success ) {
-      my $e = "Authorization successful but capture failed, custnum #".
-              $self->custnum. ': '.  $capture->result_code.
-              ": ". $capture->error_message;
-      warn $e;
-      return $e;
-    }
-
-  }
-
-  $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
-  my $cpp_captured_err = $cust_pay_pending->replace;
-  return $cpp_captured_err if $cpp_captured_err;
-
-  ###
-  # remove paycvv after initial transaction
-  ###
-
-  #false laziness w/misc/process/payment.cgi - check both to make sure working
-  # correctly
-  if ( defined $self->dbdef_table->column('paycvv')
-       && length($self->paycvv)
-       && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
-  ) {
-    my $error = $self->remove_cvv;
-    if ( $error ) {
-      warn "WARNING: error removing cvv: $error\n";
-    }
-  }
-
-  ###
-  # result handling
-  ###
-
-  if ( $transaction->is_success() ) {
-
-    my $paybatch = '';
-    if ( $payment_gateway ) { # agent override
-      $paybatch = $payment_gateway->gatewaynum. '-';
-    }
-
-    $paybatch .= "$processor:". $transaction->authorization;
-
-    $paybatch .= ':'. $transaction->order_number
-      if $transaction->can('order_number')
-      && length($transaction->order_number);
-
-    my $cust_pay = new FS::cust_pay ( {
-       'custnum'  => $self->custnum,
-       'invnum'   => $options{'invnum'},
-       'paid'     => $amount,
-       '_date'    => '',
-       'payby'    => $method2payby{$method},
-       'payinfo'  => $payinfo,
-       'paybatch' => $paybatch,
-       'paydate'  => $paydate,
-       'pkgnum'   => $options{'pkgnum'},
-    } );
-    #doesn't hurt to know, even though the dup check is in cust_pay_pending now
-    $cust_pay->payunique( $options{payunique} )
-      if defined($options{payunique}) && length($options{payunique});
-
-    my $oldAutoCommit = $FS::UID::AutoCommit;
-    local $FS::UID::AutoCommit = 0;
-    my $dbh = dbh;
-
-    #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
-
-    my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
-
-    if ( $error ) {
-      $cust_pay->invnum(''); #try again with no specific invnum
-      my $error2 = $cust_pay->insert( $options{'manual'} ?
-                                      ( 'manual' => 1 ) : ()
-                                    );
-      if ( $error2 ) {
-        # gah.  but at least we have a record of the state we had to abort in
-        # from cust_pay_pending now.
-        my $e = "WARNING: $method captured but payment not recorded - ".
-                "error inserting payment ($processor): $error2".
-                " (previously tried insert with invnum #$options{'invnum'}" .
-                ": $error ) - pending payment saved as paypendingnum ".
-                $cust_pay_pending->paypendingnum. "\n";
-        warn $e;
-        return $e;
-      }
-    }
-
-    if ( $options{'paynum_ref'} ) {
-      ${ $options{'paynum_ref'} } = $cust_pay->paynum;
-    }
-
-    $cust_pay_pending->status('done');
-    $cust_pay_pending->statustext('captured');
-    $cust_pay_pending->paynum($cust_pay->paynum);
-    my $cpp_done_err = $cust_pay_pending->replace;
-
-    if ( $cpp_done_err ) {
-
-      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-      my $e = "WARNING: $method captured but payment not recorded - ".
-              "error updating status for paypendingnum ".
-              $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
-      warn $e;
-      return $e;
-
-    } else {
-
-      $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-      if ( $options{'apply'} ) {
-        my $apply_error = $self->apply_payments_and_credits;
-        if ( $apply_error ) {
-          warn "WARNING: error applying payment: $apply_error\n";
-          #but we still should return no error cause the payment otherwise went
-          #through...
-        }
-      }
-
-      return ''; #no error
-
-    }
-
-  } else {
-
-    my $perror = "$processor error: ". $transaction->error_message;
-
-    unless ( $transaction->error_message ) {
-
-      my $t_response;
-      if ( $transaction->can('response_page') ) {
-        $t_response = {
-                        'page'    => ( $transaction->can('response_page')
-                                         ? $transaction->response_page
-                                         : ''
-                                     ),
-                        'code'    => ( $transaction->can('response_code')
-                                         ? $transaction->response_code
-                                         : ''
-                                     ),
-                        'headers' => ( $transaction->can('response_headers')
-                                         ? $transaction->response_headers
-                                         : ''
-                                     ),
-                      };
-      } else {
-        $t_response .=
-          "No additional debugging information available for $processor";
-      }
-
-      $perror .= "No error_message returned from $processor -- ".
-                 ( ref($t_response) ? Dumper($t_response) : $t_response );
-
-    }
-
-    if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
-         && $conf->exists('emaildecline')
-         && grep { $_ ne 'POST' } $self->invoicing_list
-         && ! 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) ],
-      );
-
-      $perror .= " (also received error sending decline notification: $error)"
-        if $error;
-
-    }
-
-    $cust_pay_pending->status('done');
-    $cust_pay_pending->statustext("declined: $perror");
-    my $cpp_done_err = $cust_pay_pending->replace;
-    if ( $cpp_done_err ) {
-      my $e = "WARNING: $method declined but pending payment not resolved - ".
-              "error updating status for paypendingnum ".
-              $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
-      warn $e;
-      $perror = "$e ($perror)";
-    }
-
-    return $perror;
-  }
-
-}
-
-sub _bop_recurring_billing {
-  my( $self, %opt ) = @_;
-
-  my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
-
-  if ( defined($method) && $method eq 'transaction_is_recur' ) {
-
-    return 1 if $opt{'trans_is_recur'};
-
-  } else {
-
-    my %hash = ( 'custnum' => $self->custnum,
-                 'payby'   => 'CARD',
-               );
-
-    return 1 
-      if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
-      || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
-                                                               $opt{'payinfo'} )
-                             } );
-
-  }
-
-  return 0;
-
-}
-
-
-=item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
-
-Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment realtime gateway.  See
-L<http://420.am/business-onlinepayment> for supported gateways.
-
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
-
-Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
-
-Most gateways require a reference to an original payment transaction to refund,
-so you probably need to specify a I<paynum>.
-
-I<amount> defaults to the original amount of the payment if not specified.
-
-I<reason> specifies a reason for the refund.
-
-I<paydate> specifies the expiration date for a credit card overriding the
-value from the customer record or the payment record. Specified as yyyy-mm-dd
-
-Implementation note: If I<amount> is unspecified or equal to the amount of the
-orignal payment, first an attempt is made to "void" the transaction via
-the gateway (to cancel a not-yet settled transaction) and then if that fails,
-the normal attempt is made to "refund" ("credit") the transaction via the
-gateway is attempted.
-
-#The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
-#I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
-#if set, will override the value from the customer record.
-
-#If an I<invnum> is specified, this payment (if successful) is applied to the
-#specified invoice.  If you don't specify an I<invnum> you might want to
-#call the B<apply_payments> method.
-
-=cut
-
-#some false laziness w/realtime_bop, not enough to make it worth merging
-#but some useful small subs should be pulled out
-sub realtime_refund_bop {
-  my $self = shift;
-
-  return $self->_new_realtime_refund_bop(@_)
-    if $self->_new_bop_required();
-
-  my( $method, %options ) = @_;
-  if ( $DEBUG ) {
-    warn "$me realtime_refund_bop: $method refund\n";
-    warn "  $_ => $options{$_}\n" foreach keys %options;
-  }
-
-  eval "use Business::OnlinePayment";  
-  die $@ if $@;
-
-  ###
-  # look up the original payment and optionally a gateway for that payment
-  ###
-
-  my $cust_pay = '';
-  my $amount = $options{'amount'};
-
-  my( $processor, $login, $password, @bop_options ) ;
-  my( $auth, $order_number ) = ( '', '', '' );
-
-  if ( $options{'paynum'} ) {
-
-    warn "  paynum: $options{paynum}\n" if $DEBUG > 1;
-    $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
-      or return "Unknown paynum $options{'paynum'}";
-    $amount ||= $cust_pay->paid;
-
-    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
-      or return "Can't parse paybatch for paynum $options{'paynum'}: ".
-                $cust_pay->paybatch;
-    my $gatewaynum = '';
-    ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
-
-    if ( $gatewaynum ) { #gateway for the payment to be refunded
-
-      my $payment_gateway =
-        qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
-      die "payment gateway $gatewaynum not found"
-        unless $payment_gateway;
-
-      $processor   = $payment_gateway->gateway_module;
-      $login       = $payment_gateway->gateway_username;
-      $password    = $payment_gateway->gateway_password;
-      @bop_options = $payment_gateway->options;
-
-    } else { #try the default gateway
-
-      my( $conf_processor, $unused_action );
-      ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
-        $self->default_payment_gateway($method);
-
-      return "processor of payment $options{'paynum'} $processor does not".
-             " match default processor $conf_processor"
-        unless $processor eq $conf_processor;
-
-    }
-
-
-  } else { # didn't specify a paynum, so look for agent gateway overrides
-           # like a normal transaction 
-
-    my $cardtype;
-    if ( $method eq 'CC' ) {
-      $cardtype = cardtype($self->payinfo);
-    } elsif ( $method eq 'ECHECK' ) {
-      $cardtype = 'ACH';
-    } else {
-      $cardtype = $method;
-    }
-    my $override =
-           qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                               cardtype => $cardtype,
-                                               taxclass => '',              } )
-        || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                               cardtype => '',
-                                               taxclass => '',              } );
-
-    if ( $override ) { #use a payment gateway override
-      my $payment_gateway = $override->payment_gateway;
-
-      $processor   = $payment_gateway->gateway_module;
-      $login       = $payment_gateway->gateway_username;
-      $password    = $payment_gateway->gateway_password;
-      #$action      = $payment_gateway->gateway_action;
-      @bop_options = $payment_gateway->options;
-
-    } else { #use the standard settings from the config
-
-      my $unused_action;
-      ( $processor, $login, $password, $unused_action, @bop_options ) =
-        $self->default_payment_gateway($method);
-
-    }
-
-  }
-  return "neither amount nor paynum specified" unless $amount;
-
-  my %content = (
-    'type'           => $method,
-    'login'          => $login,
-    'password'       => $password,
-    'order_number'   => $order_number,
-    'amount'         => $amount,
-    'referer'        => 'http://cleanwhisker.420.am/', #XXX fix referer :/
-  );
-  $content{authorization} = $auth
-    if length($auth); #echeck/ACH transactions have an order # but no auth
-                      #(at least with authorize.net)
-
-  my $disable_void_after;
-  if ($conf->exists('disable_void_after')
-      && $conf->config('disable_void_after') =~ /^(\d+)$/) {
-    $disable_void_after = $1;
-  }
-
-  #first try void if applicable
-  if ( $cust_pay && $cust_pay->paid == $amount
-    && (
-      ( not defined($disable_void_after) )
-      || ( time < ($cust_pay->_date + $disable_void_after ) )
-    )
-  ) {
-    warn "  attempting void\n" if $DEBUG > 1;
-    my $void = new Business::OnlinePayment( $processor, @bop_options );
-    $content{'card_number'} = $cust_pay->payinfo
-      if $cust_pay->payby eq 'CARD'
-      && $void->can('info') && $void->info('CC_void_requires_card');
-    $void->content( 'action' => 'void', %content );
-    $void->submit();
-    if ( $void->is_success ) {
-      my $error = $cust_pay->void($options{'reason'});
-      if ( $error ) {
-        # gah, even with transactions.
-        my $e = 'WARNING: Card/ACH voided but database not updated - '.
-                "error voiding payment: $error";
-        warn $e;
-        return $e;
-      }
-      warn "  void successful\n" if $DEBUG > 1;
-      return '';
-    }
-  }
-
-  warn "  void unsuccessful, trying refund\n"
-    if $DEBUG > 1;
-
-  #massage data
-  my $address = $self->address1;
-  $address .= ", ". $self->address2 if $self->address2;
-
-  my($payname, $payfirst, $paylast);
-  if ( $self->payname && $method ne 'ECHECK' ) {
-    $payname = $self->payname;
-    $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";
-  }
-
-  my @invoicing_list = $self->invoicing_list_emailonly;
-  if ( $conf->exists('emailinvoiceautoalways')
-       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
-       || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
-    push @invoicing_list, $self->all_emails;
-  }
-
-  my $email = ($conf->exists('business-onlinepayment-email-override'))
-              ? $conf->config('business-onlinepayment-email-override')
-              : $invoicing_list[0];
-
-  my $payip = exists($options{'payip'})
-                ? $options{'payip'}
-                : $self->payip;
-  $content{customer_ip} = $payip
-    if length($payip);
-
-  my $payinfo = '';
-  if ( $method eq 'CC' ) {
-
-    if ( $cust_pay ) {
-      $content{card_number} = $payinfo = $cust_pay->payinfo;
-      (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
-        =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
-        ($content{expiration} = "$2/$1");  # where available
-    } else {
-      $content{card_number} = $payinfo = $self->payinfo;
-      (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
-        =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-      $content{expiration} = "$2/$1";
-    }
-
-  } elsif ( $method eq 'ECHECK' ) {
-
-    if ( $cust_pay ) {
-      $payinfo = $cust_pay->payinfo;
-    } else {
-      $payinfo = $self->payinfo;
-    } 
-    ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
-    $content{bank_name} = $self->payname;
-    $content{account_type} = 'CHECKING';
-    $content{account_name} = $payname;
-    $content{customer_org} = $self->company ? 'B' : 'I';
-    $content{customer_ssn} = $self->ss;
-  } elsif ( $method eq 'LEC' ) {
-    $content{phone} = $payinfo = $self->payinfo;
-  }
-
-  #then try refund
-  my $refund = new Business::OnlinePayment( $processor, @bop_options );
-  my %sub_content = $refund->content(
-    'action'         => 'credit',
-    'customer_id'    => $self->custnum,
-    'last_name'      => $paylast,
-    'first_name'     => $payfirst,
-    'name'           => $payname,
-    'address'        => $address,
-    'city'           => $self->city,
-    'state'          => $self->state,
-    'zip'            => $self->zip,
-    'country'        => $self->country,
-    'email'          => $email,
-    'phone'          => $self->daytime || $self->night,
-    %content, #after
-  );
-  warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
-    if $DEBUG > 1;
-  $refund->submit();
-
-  return "$processor error: ". $refund->error_message
-    unless $refund->is_success();
-
-  my %method2payby = (
-    'CC'     => 'CARD',
-    'ECHECK' => 'CHEK',
-    'LEC'    => 'LECB',
-  );
-
-  my $paybatch = "$processor:". $refund->authorization;
-  $paybatch .= ':'. $refund->order_number
-    if $refund->can('order_number') && $refund->order_number;
-
-  while ( $cust_pay && $cust_pay->unapplied < $amount ) {
-    my @cust_bill_pay = $cust_pay->cust_bill_pay;
-    last unless @cust_bill_pay;
-    my $cust_bill_pay = pop @cust_bill_pay;
-    my $error = $cust_bill_pay->delete;
-    last if $error;
-  }
-
-  my $cust_refund = new FS::cust_refund ( {
-    'custnum'  => $self->custnum,
-    'paynum'   => $options{'paynum'},
-    'refund'   => $amount,
-    '_date'    => '',
-    'payby'    => $method2payby{$method},
-    'payinfo'  => $payinfo,
-    'paybatch' => $paybatch,
-    'reason'   => $options{'reason'} || 'card or ACH refund',
-  } );
-  my $error = $cust_refund->insert;
-  if ( $error ) {
-    $cust_refund->paynum(''); #try again with no specific paynum
-    my $error2 = $cust_refund->insert;
-    if ( $error2 ) {
-      # gah, even with transactions.
-      my $e = 'WARNING: Card/ACH refunded but database not updated - '.
-              "error inserting refund ($processor): $error2".
-              " (previously tried insert with paynum #$options{'paynum'}" .
-              ": $error )";
-      warn $e;
-      return $e;
-    }
-  }
-
-  ''; #no error
-
-}
-
-# does the configuration indicate the new bop routines are required?
-
-sub _new_bop_required {
-  my $self = shift;
-
-  my $botpp = 'Business::OnlineThirdPartyPayment';
-
-  return 1
-    if ( $conf->config('business-onlinepayment-namespace') eq $botpp ||
-         scalar( grep { $_->gateway_namespace eq $botpp } 
-                 qsearch( 'payment_gateway', { 'disabled' => '' } )
-               )
-       )
-  ;
+=cut
 
-  '';
-}
-  
 =item realtime_collect [ OPTION => VALUE ... ]
 
 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
@@ -5011,7 +4315,7 @@ sub realtime_collect {
 
 }
 
-=item _realtime_bop { [ ARG => VALUE ... ] }
+=item realtime_bop { [ ARG => VALUE ... ] }
 
 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
 via a Business::OnlinePayment realtime gateway.  See
@@ -5021,7 +4325,7 @@ Required arguments in the hashref are I<method>, and I<amount>
 
 Available methods are: I<CC>, I<ECHECK> and I<LEC>
 
-Available optional arguments are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+Available optional arguments are: I<description>, I<invnum>, I<apply>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
 
 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
 I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
@@ -5033,7 +4337,9 @@ option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
 specified invoice.  If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method.
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
 
 I<quiet> can be set true to surpress email decline notices.
 
@@ -5051,6 +4357,33 @@ I<depend_jobnum> allows payment capture to unlock export jobs
 =cut
 
 # some helper routines
+sub _bop_recurring_billing {
+  my( $self, %opt ) = @_;
+
+  my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
+
+  if ( defined($method) && $method eq 'transaction_is_recur' ) {
+
+    return 1 if $opt{'trans_is_recur'};
+
+  } else {
+
+    my %hash = ( 'custnum' => $self->custnum,
+                 'payby'   => 'CARD',
+               );
+
+    return 1 
+      if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
+      || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
+                                                               $opt{'payinfo'} )
+                             } );
+
+  }
+
+  return 0;
+
+}
+
 sub _payment_gateway {
   my ($self, $options) = @_;
 
@@ -5075,6 +4408,7 @@ sub _bop_options {
   $options->{payment_gateway}->gatewaynum
     ? $options->{payment_gateway}->options
     : @{ $options->{payment_gateway}->get('options') };
+
 }
 
 sub _bop_defaults {
@@ -5101,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);
 
@@ -5119,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{account_name} = "$content{payfirst} $content{paylast}"
-    if $options->{method} eq 'ECHECK';
+  $content{last_name} = $paylast;
+  $content{first_name} = $payfirst;
 
-  $content{name} = $options->{payname};
-  $content{name} = $content{account_name} if exists($content{account_name});
+  $content{name} = $payname;
+
+  $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}
@@ -5140,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 = (
@@ -5152,7 +4495,7 @@ my %bop_method2payby = (
   'LEC'    => 'LECB',
 );
 
-sub _new_realtime_bop {
+sub realtime_bop {
   my $self = shift;
 
   my %options = ();
@@ -5219,13 +4562,8 @@ sub _new_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')
@@ -5291,6 +4629,9 @@ sub _new_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'}
@@ -5375,7 +4716,7 @@ sub _new_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
@@ -5390,6 +4731,8 @@ sub _new_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 ) {
@@ -5442,6 +4785,8 @@ sub _new_realtime_bop {
 
     $capture->content( %capture );
 
+    $capture->test_transaction(1)
+      if $conf->exists('business-onlinepayment-test_transaction');
     $capture->submit();
 
     unless ( $capture->is_success ) {
@@ -5471,6 +4816,25 @@ sub _new_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
   ###
 
@@ -5593,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,
@@ -5747,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;
@@ -5955,7 +5333,7 @@ sub remove_cvv {
   '';
 }
 
-=item _new_realtime_refund_bop METHOD [ OPTION => VALUE ... ]
+=item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
 
 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
 via a Business::OnlinePayment realtime gateway.  See
@@ -5993,11 +5371,11 @@ gateway is attempted.
 
 #some false laziness w/realtime_bop, not enough to make it worth merging
 #but some useful small subs should be pulled out
-sub _new_realtime_refund_bop {
+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;
@@ -6115,10 +5493,22 @@ sub _new_realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
-    $content{'card_number'} = $cust_pay->payinfo
-      if $cust_pay->payby eq 'CARD'
-      && $void->can('info') && $void->info('CC_void_requires_card');
+    if ( $void->can('info') ) {
+      if ( $cust_pay->payby eq 'CARD'
+           && $void->info('CC_void_requires_card') )
+      {
+        $content{'card_number'} = $cust_pay->payinfo;
+      } elsif ( $cust_pay->payby eq 'CHEK'
+                && $void->info('ECHECK_void_requires_account') )
+      {
+        ( $content{'account_number'}, $content{'routing_code'} ) =
+          split('@', $cust_pay->payinfo);
+        $content{'name'} = $self->get('first'). ' '. $self->get('last');
+      }
+    }
     $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'});
@@ -6221,6 +5611,8 @@ sub _new_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
@@ -6659,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) );
 
 }
 
@@ -6751,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
@@ -6780,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
@@ -6810,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
@@ -6824,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
@@ -6844,13 +6245,37 @@ 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 ... ] ] ]
+
+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
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
+
+Available options are:
+
+=over 4
+
+=item unapplied_date
+
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+
+=back
+
+=cut
+
+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) );
 }
 
 =item balance_pkgnum PKGNUM
@@ -7255,7 +6680,7 @@ sub referral_cust_main_ncancelled {
 
 Like referral_cust_main, except returns a flat list of all unsuspended (and
 uncancelled) packages for each customer.  The number of items in this list may
-be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
+be useful for commission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
 
 =cut
 
@@ -7317,8 +6742,10 @@ sub credit {
     $cust_credit->set('reason', $reason)
   }
 
-  $cust_credit->addlinfo( delete $options{'addlinfo'} )
-    if exists($options{'addlinfo'});
+  for (qw( addlinfo eventnum )) {
+    $cust_credit->$_( delete $options{$_} )
+      if exists($options{$_});
+  }
 
   $cust_credit->insert(%options);
 
@@ -7371,12 +6798,14 @@ sub charge {
   my ( $pkg, $comment, $additional );
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
+  my $no_auto = '';
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
+    $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
@@ -7454,6 +6883,7 @@ sub charge {
     'pkgpart'    => $pkgpart,
     'quantity'   => $quantity,
     'start_date' => $start_date,
+    'no_auto'    => $no_auto,
   } );
 
   $error = $cust_pkg->insert;
@@ -7670,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
@@ -7680,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
@@ -7853,9 +7320,11 @@ sub geocode {
                ? 'ship_'
                : '';
 
-  my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
+  my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
     if $self->country eq 'US';
 
+  $zip ||= '';
+  $plus4 ||= '';
   #CCH specific location stuff
   my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
 
@@ -7883,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)
@@ -7899,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;
@@ -7934,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
@@ -8043,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
 
@@ -8054,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
 
@@ -8066,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
@@ -8081,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
@@ -8146,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).
 
@@ -8181,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
@@ -8188,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'} || '';
 
@@ -8224,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 );
@@ -8268,8 +7772,8 @@ sub _money_table_where {
 
 (Class method)
 
-Returns a qsearch hash expression to search for parameters specified in HREF.
-Valid parameters are
+Returns a qsearch hash expression to search for parameters specified in
+HASHREF.  Valid parameters are
 
 =over 4
 
@@ -8325,10 +7829,19 @@ sub search {
   }
 
   ##
+  # do the same for user
+  ##
+
+  if ( $params->{'usernum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_main.usernum = $1";
+  }
+
+  ##
   # 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();
@@ -8359,13 +7872,23 @@ sub search {
 
     next unless exists($params->{$field});
 
-    my($beginning, $ending) = @{$params->{$field}};
+    my($beginning, $ending, $hour) = @{$params->{$field}};
 
     push @where,
       "cust_main.$field IS NOT NULL",
       "cust_main.$field >= $beginning",
       "cust_main.$field <= $ending";
 
+    # XXX: do this for mysql and/or pull it out of here
+    if(defined $hour) {
+      if ($dbh->{Driver}->{Name} eq 'Pg') {
+        push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
+      }
+      else {
+        warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
+      }
+    }
+
     $orderby ||= "ORDER BY cust_main.$field";
 
   }
@@ -8586,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'});
@@ -8607,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;
@@ -9266,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
@@ -9379,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) = @_;
 
@@ -9442,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',
@@ -9451,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 
@@ -9462,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
@@ -9511,14 +9086,7 @@ sub _agent_plandata {
   
   my $agentnum = $self->agentnum;
 
-  my $regexp = '';
-  if ( driver_name =~ /^Pg/i ) {
-    $regexp = '~';
-  } elsif ( driver_name =~ /^mysql/i ) {
-    $regexp = 'REGEXP';
-  } else {
-    die "don't know how to use regular expressions in ". driver_name. " databases";
-  }
+  my $regexp = regexp_sql();
 
   my $part_event_option =
     qsearchs({
@@ -9567,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
@@ -9584,6 +9173,10 @@ sub _upgrade_data { #class method
   my $sth = dbh->prepare($sql) or die dbh->errstr;
   $sth->execute or die $sth->errstr;
 
+  local($ignore_expired_card) = 1;
+  local($skip_fuzzyfiles) = 1;
+  $class->_upgrade_otaker(%opts);
+
 }
 
 =back