RT#7266: aging report "as of" date now limits applied payments
[freeside.git] / FS / FS / cust_main.pm
index 501fca4..c1a8aaf 100644 (file)
@@ -2,8 +2,13 @@ package FS::cust_main;
 
 require 5.006;
 use strict;
-use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
-             $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
+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
+             @paytypes
+           );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Safe;
 use Carp;
@@ -21,7 +26,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;
@@ -41,6 +46,7 @@ use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_location;
+use FS::cust_class;
 use FS::cust_main_exemption;
 use FS::cust_tax_adjustment;
 use FS::tax_rate;
@@ -61,11 +67,8 @@ 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;
@@ -77,9 +80,11 @@ $DEBUG = 0;
 $me = '[FS::cust_main]';
 
 $import = 0;
-$skip_fuzzyfiles = 0;
 $ignore_expired_card = 0;
 
+$skip_fuzzyfiles = 0;
+@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
+
 @encrypted_fields = ('payinfo', 'paycvv');
 sub nohistory_fields { ('paycvv'); }
 
@@ -297,9 +302,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
 
@@ -1492,9 +1497,7 @@ sub queue_fuzzyfiles_update {
   my $dbh = dbh;
 
   my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-  my $error = $queue->insert( map $self->getfield($_),
-                                  qw(first last company)
-                            );
+  my $error = $queue->insert( map $self->getfield($_), @fuzzyfields );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "queueing job (transaction rolled back): $error";
@@ -1502,9 +1505,7 @@ sub queue_fuzzyfiles_update {
 
   if ( $self->ship_last ) {
     $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-    $error = $queue->insert( map $self->getfield("ship_$_"),
-                                 qw(first last company)
-                           );
+    $error = $queue->insert( map $self->getfield("ship_$_"), @fuzzyfields );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1535,6 +1536,7 @@ sub check {
     || $self->ut_number('agentnum')
     || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
+    || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
     || $self->ut_name('first')
@@ -1908,6 +1910,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.
@@ -1951,6 +1972,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.
@@ -2012,6 +2088,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');
@@ -2303,12 +2382,61 @@ sub agent {
   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
 }
 
+=item cust_class
+
+Returns the customer class, as an FS::cust_class object, or the empty string
+if there is no customer class.
+
+=cut
+
+sub cust_class {
+  my $self = shift;
+  if ( $self->classnum ) {
+    qsearchs('cust_class', { 'classnum' => $self->classnum } );
+  } else {
+    return '';
+  } 
+}
+
+=item categoryname 
+
+Returns the customer category name, or the empty string if there is no customer
+category.
+
+=cut
+
+sub categoryname {
+  my $self = shift;
+  my $cust_class = $self->cust_class;
+  $cust_class
+    ? $cust_class->categoryname
+    : '';
+}
+
+=item classname 
+
+Returns the customer class name, or the empty string if there is no customer
+class.
+
+=cut
+
+sub classname {
+  my $self = shift;
+  my $cust_class = $self->cust_class;
+  $cust_class
+    ? $cust_class->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:
 
@@ -2334,6 +2462,12 @@ 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)
@@ -2348,34 +2482,70 @@ 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;
+
+  $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; }
+  }
+
+  $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;
+  $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; }
+  }
 
   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; }
+    }
   }
 
+  '';
+
 }
 
 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,
@@ -2383,15 +2553,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' =>
@@ -2415,6 +2585,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);
@@ -2423,12 +2595,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
@@ -2462,7 +2633,7 @@ An array ref of specific packages (objects) to attempt billing, instead trying a
 
 =item not_pkgpart
 
-A hashref of pkgparts to exclude from this billing run.
+A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
 
 =item invoice_time
 
@@ -2475,6 +2646,11 @@ typically might mean not charging the normal recurring fee but only usage
 fees since the last billing. Setup charges may be charged.  Not all package
 plans support this feature (they tend to charge 0).
 
+=item invoice_terms
+
+Optional terms to be printed on this invoice.  Otherwise, customer-specific
+terms or the default terms are used.
+
 =back
 
 =cut
@@ -2489,6 +2665,10 @@ sub bill {
   my $invoice_time = $options{'invoice_time'} || $time;
 
   $options{'not_pkgpart'} ||= {};
+  $options{'not_pkgpart'} = { map { $_ => 1 }
+                                  split(/\s*,\s*/, $options{'not_pkgpart'})
+                            }
+    unless ref($options{'not_pkgpart'});
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -2514,15 +2694,21 @@ sub bill {
     return $error;
   }
 
-  my @cust_bill_pkg = ();
+  #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?
@@ -2545,14 +2731,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,
@@ -2566,51 +2754,194 @@ 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 ) ) {
+
+        $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;
+          }
         }
+
       }
 
     }
 
+    my $listref_or_error =
+      $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
+
+    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;
+    }
+
+    #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 $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,
+      };
+
+    }
+
+    my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
+
+    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 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 {
+      &{$hook}; #($self) ?
+    };
+    if ( $@ ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$@ running precommit hook $hook\n";
+    }
   }
+  
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
+}
+
+=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
+
+This is a weird one.  Perhaps it should not even be exposed.
+
+Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
+Usually used internally by bill method B<bill>.
+
+If there is an error, returns the error, otherwise returns reference to a
+list of line items suitable for insertion.
+
+=over 4
+
+=item LINEITEMREF
+
+An array ref of the line items being billed.
+
+=item TAXHASHREF
+
+A strange beast.  The keys to this hash are internal identifiers consisting
+of the name of the tax object type, a space, and its unique identifier ( e.g.
+ 'cust_main_county 23' ).  The values of the hash are listrefs.  The first
+item in the list is the tax object.  The remaining items are either line
+items or floating point values (currency amounts).
+
+The taxes are calculated on this entity.  Calculated exemption records are
+transferred to the LINEITEMREF items on the assumption that they are related.
+
+Read the source.
+
+=item INVOICE_TIME
+
+This specifies the date appearing on the associated invoice.  Some
+jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
+
+=back
+
+=cut
+sub calculate_taxes {
+  my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
+
+  my @tax_line_items = ();
 
   warn "having a look at the taxes we found...\n" if $DEBUG > 2;
 
@@ -2630,20 +2961,18 @@ sub bill {
   # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
   my %tax_rate_location = ();
 
-  foreach my $tax ( keys %taxlisthash ) {
-    my $tax_object = shift @{ $taxlisthash{$tax} };
+  foreach my $tax ( keys %$taxlisthash ) {
+    my $tax_object = shift @{ $taxlisthash->{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
-    warn " ". join('/', @{ $taxlisthash{$tax} } ). "\n" if $DEBUG > 2;
+    warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
     my $hashref_or_error =
-      $tax_object->taxline( $taxlisthash{$tax},
+      $tax_object->taxline( $taxlisthash->{$tax},
                             'custnum'      => $self->custnum,
                             'invoice_time' => $invoice_time
                           );
-    unless ( ref($hashref_or_error) ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $hashref_or_error;
-    }
-    unshift @{ $taxlisthash{$tax} }, $tax_object;
+    return $hashref_or_error unless ref($hashref_or_error);
+
+    unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
     my $name   = $hashref_or_error->{'name'};
     my $amount = $hashref_or_error->{'amount'};
@@ -2683,9 +3012,9 @@ sub bill {
   }
 
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
-  my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
-  foreach my $tax ( keys %taxlisthash ) {
-    foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
+  my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
+  foreach my $tax ( keys %$taxlisthash ) {
+    foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
       next unless ref($_) eq 'FS::cust_bill_pkg';
 
       push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
@@ -2715,7 +3044,6 @@ sub bill {
     next unless $tax;
 
     $tax = sprintf('%.2f', $tax );
-    $total_setup = sprintf('%.2f', $total_setup+$tax );
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
@@ -2734,7 +3062,7 @@ sub bill {
 
     }
 
-    push @cust_bill_pkg, new FS::cust_bill_pkg {
+    push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
       'setup'    => $tax,
       'recur'    => 0,
@@ -2748,87 +3076,9 @@ sub bill {
 
   }
 
-  #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 $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
-    $total_setup = sprintf('%.2f', $total_setup+$tax );
-
-    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,
-    };
-
-  }
-
-  my $charged = sprintf('%.2f', $total_setup + $total_recur );
-
-  my @cust_bill = $self->cust_bill;
-  my $balance = $self->balance;
-  my $previous_balance = scalar(@cust_bill)
-                           ?  $cust_bill[$#cust_bill]->billing_balance
-                           :  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,
-  } );
-  $error = $cust_bill->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    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 $hook ( @precommit_hooks ) { 
-    eval {
-      &{$hook}; #($self) ?
-    };
-    if ( $@ ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "$@ running precommit hook $hook\n";
-    }
-  }
-  
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
+  \@tax_line_items;
 }
 
-
 sub _make_lines {
   my ($self, %params) = @_;
 
@@ -2848,7 +3098,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);
@@ -2933,6 +3183,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';
@@ -3012,6 +3263,7 @@ sub _make_lines {
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
+        'discounts' => \@discounts,
         'hidden'    => $part_pkg->hidden,
       };
 
@@ -3036,7 +3288,7 @@ sub _make_lines {
       ###
 
       my $error = 
-        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart);
+        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
       return $error if $error;
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
@@ -3057,6 +3309,7 @@ sub _handle_taxes {
   my $cust_pkg = shift;
   my $invoice_time = shift;
   my $real_pkgpart = shift;
+  my $options = shift;
 
   my %cust_bill_pkg = ();
   my %taxes = ();
@@ -3064,8 +3317,8 @@ sub _handle_taxes {
   my @classes;
   #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
-  push @classes, 'setup' if $cust_bill_pkg->setup;
-  push @classes, 'recur' if $cust_bill_pkg->recur;
+  push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
+  push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
 
   if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
 
@@ -3094,7 +3347,7 @@ sub _handle_taxes {
 
     } else {
 
-      my @loc_keys = qw( state county country );
+      my @loc_keys = qw( city county state country );
       my %taxhash;
       if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
         my $cust_location = $cust_pkg->cust_location;
@@ -3109,15 +3362,24 @@ sub _handle_taxes {
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      my @taxes = qsearch( 'cust_main_county', \%taxhash );
-
+      my @taxes = ();
       my %taxhash_elim = %taxhash;
+      my @elim = qw( city county state );
+      do { 
 
-      my @elim = qw( taxclass county state );
-      while ( !scalar(@taxes) && scalar(@elim) ) {
-        $taxhash_elim{ shift(@elim) } = '';
+        #first try a match with taxclass
         @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
-      }
+
+        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+          #then try a match without taxclass
+          my %no_taxclass = %taxhash_elim;
+          $no_taxclass{ 'taxclass' } = '';
+          @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+        }
+
+        $taxhash_elim{ shift(@elim) } = '';
+
+      } while ( !scalar(@taxes) && scalar(@elim) );
 
       @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
                     @taxes
@@ -3699,7 +3961,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
@@ -3757,1040 +4019,71 @@ sub retry_realtime {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  #a little false laziness w/due_cust_event (not too bad, really)
-
-  my $join = FS::part_event_condition->join_conditions_sql;
-  my $order = FS::part_event_condition->order_conditions_sql;
-  my $mine = 
-  '( '
-   . join ( ' OR ' , map { 
-    "( part_event.eventtable = " . dbh->quote($_) 
-    . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
-   } FS::part_event->eventtables)
-   . ') ';
-
-  #here is the agent virtualization
-  my $agent_virt = " (    part_event.agentnum IS NULL
-                       OR part_event.agentnum = ". $self->agentnum. ' )';
-
-  #XXX this shouldn't be hardcoded, actions should declare it...
-  my @realtime_events = qw(
-    cust_bill_realtime_card
-    cust_bill_realtime_check
-    cust_bill_realtime_lec
-    cust_bill_batch
-  );
-
-  my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
-                                                  @realtime_events
-                                     ).
-                          ' ) ';
-
-  my @cust_event = qsearchs({
-    'table'     => 'cust_event',
-    'select'    => 'cust_event.*',
-    'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
-    'hashref'   => { 'status' => 'done' },
-    'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
-                   " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
-  });
-
-  my %seen_invnum = ();
-  foreach my $cust_event (@cust_event) {
-
-    #max one for the customer, one for each open invoice
-    my $cust_X = $cust_event->cust_X;
-    next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
-                          ? $cust_X->invnum
-                          : 0
-                        }++
-         or $cust_event->part_event->eventtable eq 'cust_bill'
-            && ! $cust_X->owed;
-
-    my $error = $cust_event->retry;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "error scheduling event for retry: $error";
-    }
-
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
-# 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
-"Internet services".
-
-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.
-
-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, %options ) = @_;
-  if ( $DEBUG ) {
-    warn "$me realtime_bop: $method $amount\n";
-    warn "  $_ => $options{$_}\n" foreach keys %options;
-  }
-
-  $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;
-      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 = $conf->config('credit_card-recurring_billing_flag');
-
-  if ( $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 );
-    $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',
-  );
+  #a little false laziness w/due_cust_event (not too bad, really)
 
-  my $paybatch = "$processor:". $refund->authorization;
-  $paybatch .= ':'. $refund->order_number
-    if $refund->can('order_number') && $refund->order_number;
+  my $join = FS::part_event_condition->join_conditions_sql;
+  my $order = FS::part_event_condition->order_conditions_sql;
+  my $mine = 
+  '( '
+   . join ( ' OR ' , map { 
+    "( part_event.eventtable = " . dbh->quote($_) 
+    . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
+   } FS::part_event->eventtables)
+   . ') ';
 
-  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;
-  }
+  #here is the agent virtualization
+  my $agent_virt = " (    part_event.agentnum IS NULL
+                       OR part_event.agentnum = ". $self->agentnum. ' )';
 
-  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;
-    }
-  }
+  #XXX this shouldn't be hardcoded, actions should declare it...
+  my @realtime_events = qw(
+    cust_bill_realtime_card
+    cust_bill_realtime_check
+    cust_bill_realtime_lec
+    cust_bill_batch
+  );
 
-  ''; #no error
+  my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
+                                                  @realtime_events
+                                     ).
+                          ' ) ';
 
-}
+  my @cust_event = qsearchs({
+    'table'     => 'cust_event',
+    'select'    => 'cust_event.*',
+    'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
+    'hashref'   => { 'status' => 'done' },
+    'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
+                   " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
+  });
 
-# does the configuration indicate the new bop routines are required?
+  my %seen_invnum = ();
+  foreach my $cust_event (@cust_event) {
 
-sub _new_bop_required {
-  my $self = shift;
+    #max one for the customer, one for each open invoice
+    my $cust_X = $cust_event->cust_X;
+    next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
+                          ? $cust_X->invnum
+                          : 0
+                        }++
+         or $cust_event->part_event->eventtable eq 'cust_bill'
+            && ! $cust_X->owed;
 
-  my $botpp = 'Business::OnlineThirdPartyPayment';
+    my $error = $cust_event->retry;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error scheduling event for retry: $error";
+    }
 
-  return 1
-    if ( $conf->config('business-onlinepayment-namespace') eq $botpp ||
-         scalar( grep { $_->gateway_namespace eq $botpp } 
-                 qsearch( 'payment_gateway', { 'disabled' => '' } )
-               )
-       )
-  ;
+  }
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
+
 }
-  
+
+
+=cut
 
 =item realtime_collect [ OPTION => VALUE ... ]
 
@@ -4815,11 +4108,14 @@ 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
-"Internet services".
+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.
+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.
 
@@ -4850,7 +4146,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
@@ -4860,18 +4156,21 @@ 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,
 if set, will override the value from the customer record.
 
 I<description> is a free-text field passed to the gateway.  It defaults to
-"Internet services".
+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.
+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.
 
@@ -4889,6 +4188,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) = @_;
 
@@ -4918,7 +4244,18 @@ sub _bop_options {
 sub _bop_defaults {
   my ($self, $options) = @_;
 
-  $options->{description} ||= 'Internet services';
+  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';
+    }
+  }
+
   $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
   $options->{invnum} ||= '';
   $options->{payname} = $self->payname unless exists( $options->{payname} );
@@ -4979,7 +4316,7 @@ my %bop_method2payby = (
   'LEC'    => 'LECB',
 );
 
-sub _new_realtime_bop {
+sub realtime_bop {
   my $self = shift;
 
   my %options = ();
@@ -5502,6 +4839,16 @@ sub _realtime_bop_result {
     } 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
 
     }
@@ -5772,7 +5119,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
@@ -5810,7 +5157,7 @@ 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 = ();
@@ -5932,6 +5279,19 @@ sub _new_realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    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->submit();
     if ( $void->is_success ) {
@@ -6363,7 +5723,6 @@ A hash of optional arguments may be passed.  Currently "manual" is supported.
 If true, a payment receipt is sent instead of a statement when
 'payment_receipt_email' configuration option is set.
 
-
 Dies if there is an error.
 
 =cut
@@ -6668,6 +6027,36 @@ sub balance_date {
   );
 }
 
+=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Returns the balance for this customer, only considering invoices with date
+earlier than START_TIME, and optionally not later than END_TIME
+(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
 
 Returns the balance for this customer's specific package when using
@@ -7070,7 +6459,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
 
@@ -7132,8 +6521,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);
 
@@ -7164,6 +6555,13 @@ New-style, with a hashref of options:
                                     #vendor taxation
                                     'taxproduct' => 2,  #part_pkg_taxproduct
                                     'override'   => {}, #XXX describe
+
+                                    #will be filled in with the new object
+                                    'cust_pkg_ref' => \$cust_pkg,
+
+                                    #generate an invoice immediately
+                                    'bill_now' => 0,
+                                    'invoice_terms' => '', #with these terms
                                   }
                                 );
 
@@ -7179,10 +6577,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);
@@ -7192,6 +6594,9 @@ sub charge {
     $additional = $_[0]->{additional} || [];
     $taxproduct = $_[0]->{taxproductnum};
     $override   = { '' => $_[0]->{tax_override} };
+    $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
+    $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
+    $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
   } else {
     $amount     = shift;
     $quantity   = 1;
@@ -7220,7 +6625,7 @@ sub charge {
     'plan'          => 'flat',
     'freq'          => 0,
     'disabled'      => 'Y',
-    'classnum'      => $classnum ? $classnum : '',
+    'classnum'      => ( $classnum ? $classnum : '' ),
     'setuptax'      => $setuptax,
     'taxclass'      => $taxclass,
     'taxproductnum' => $taxproduct,
@@ -7257,16 +6662,29 @@ sub charge {
     'pkgpart'    => $pkgpart,
     'quantity'   => $quantity,
     'start_date' => $start_date,
+    'no_auto'    => $no_auto,
   } );
 
   $error = $cust_pkg->insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
+  } elsif ( $cust_pkg_ref ) {
+    ${$cust_pkg_ref} = $cust_pkg;
+  }
+
+  if ( $bill_now ) {
+    my $error = $self->bill( 'invoice_terms' => $invoice_terms,
+                             'pkg_list'      => [ $cust_pkg ],
+                           );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }   
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+  return '';
 
 }
 
@@ -7305,6 +6723,7 @@ Returns all the invoices (see L<FS::cust_bill>) for this customer.
 
 sub cust_bill {
   my $self = shift;
+  map { $_ } #return $self->num_cust_bill unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch('cust_bill', { 'custnum' => $self->custnum, } )
 }
@@ -7336,6 +6755,7 @@ Returns all the statements (see L<FS::cust_statement>) for this customer.
 
 sub cust_statement {
   my $self = shift;
+  map { $_ } #return $self->num_cust_statement unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch('cust_statement', { 'custnum' => $self->custnum, } )
 }
@@ -7348,6 +6768,7 @@ Returns all the credits (see L<FS::cust_credit>) for this customer.
 
 sub cust_credit {
   my $self = shift;
+  map { $_ } #return $self->num_cust_credit unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
 }
@@ -7361,6 +6782,7 @@ package when using experimental package balances.
 
 sub cust_credit_pkgnum {
   my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_credit_pkgnum($pkgnum) unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit', { 'custnum' => $self->custnum,
                               'pkgnum'  => $pkgnum,
@@ -7376,10 +6798,26 @@ Returns all the payments (see L<FS::cust_pay>) for this customer.
 
 sub cust_pay {
   my $self = shift;
+  return $self->num_cust_pay unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
 }
 
+=item num_cust_pay
+
+Returns the number of payments (see L<FS::cust_pay>) for this customer.  Also
+called automatically when the cust_pay method is used in a scalar context.
+
+=cut
+
+sub num_cust_pay {
+  my $self = shift;
+  my $sql = "SELECT COUNT(*) FROM cust_pay WHERE custnum = ?";
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute($self->custnum) or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
+
 =item cust_pay_pkgnum
 
 Returns all the payments (see L<FS::cust_pay>) for this customer's specific
@@ -7389,6 +6827,7 @@ package when using experimental package balances.
 
 sub cust_pay_pkgnum {
   my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_pay_pkgnum($pkgnum) unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_pay', { 'custnum' => $self->custnum,
                            'pkgnum'  => $pkgnum,
@@ -7404,6 +6843,7 @@ Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
 
 sub cust_pay_void {
   my $self = shift;
+  map { $_ } #return $self->num_cust_pay_void unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
 }
@@ -7416,6 +6856,7 @@ Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
 
 sub cust_pay_batch {
   my $self = shift;
+  map { $_ } #return $self->num_cust_pay_batch unless wantarray;
   sort { $a->paybatchnum <=> $b->paybatchnum }
     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
 }
@@ -7463,6 +6904,7 @@ Returns all the refunds (see L<FS::cust_refund>) for this customer.
 
 sub cust_refund {
   my $self = shift;
+  map { $_ } #return $self->num_cust_refund unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
 }
@@ -7620,9 +7062,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'";
 
@@ -7948,6 +7392,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
@@ -7955,10 +7405,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'} || '';
 
@@ -8031,12 +7483,12 @@ sub _money_table_where {
 
 }
 
-=item search_sql HASHREF
+=item search HASHREF
 
 (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
 
@@ -8056,6 +7508,10 @@ listref of start date, end date
 
 listref
 
+=item paydate_year
+
+=item paydate_month
+
 =item current_balance
 
 listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
@@ -8070,7 +7526,7 @@ bool
 
 =cut
 
-sub search_sql {
+sub search {
   my ($class, $params) = @_;
 
   my $dbh = dbh;
@@ -8088,6 +7544,15 @@ sub search_sql {
   }
 
   ##
+  # do the same for user
+  ##
+
+  if ( $params->{'usernum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_main.usernum = $1";
+  }
+
+  ##
   # parse status
   ##
 
@@ -8122,35 +7587,119 @@ sub search_sql {
 
     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";
 
   }
 
   ###
+  # classnum
+  ###
+
+  if ( $params->{'classnum'} ) {
+
+    my @classnum = ref( $params->{'classnum'} )
+                     ? @{ $params->{'classnum'} }
+                     :  ( $params->{'classnum'} );
+
+    @classnum = grep /^(\d*)$/, @classnum;
+
+    if ( @classnum ) {
+      push @where, '( '. join(' OR ', map {
+                                            $_ ? "cust_main.classnum = $_"
+                                               : "cust_main.classnum IS NULL"
+                                          }
+                                          @classnum
+                             ).
+                   ' )';
+    }
+
+  }
+
+  ###
   # payby
   ###
 
-  my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
-  if ( @payby ) {
-    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
+  if ( $params->{'payby'} ) {
+
+    my @payby = ref( $params->{'payby'} )
+                  ? @{ $params->{'payby'} }
+                  :  ( $params->{'payby'} );
+
+    @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
+
+    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
+      if @payby;
+
+  }
+
+  ###
+  # paydate_year / paydate_month
+  ###
+
+  if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
+    my $year = $1;
+    $params->{'paydate_month'} =~ /^(\d\d?)$/
+      or die "paydate_year without paydate_month?";
+    my $month = $1;
+
+    push @where,
+      'paydate IS NOT NULL',
+      "paydate != ''",
+      "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
+;
+  }
+
+  ###
+  # invoice terms
+  ###
+
+  if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
+    my $terms = $1;
+    if ( $1 eq 'NULL' ) {
+      push @where,
+        "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
+    } else {
+      push @where,
+        "cust_main.invoice_terms IS NOT NULL",
+        "cust_main.invoice_terms = '$1'";
+    }
   }
 
   ##
   # amounts
   ##
 
-  #my $balance_sql = $class->balance_sql();
-  my $balance_sql = FS::cust_main->balance_sql();
+  if ( $params->{'current_balance'} ) {
+
+    #my $balance_sql = $class->balance_sql();
+    my $balance_sql = FS::cust_main->balance_sql();
+
+    my @current_balance =
+      ref( $params->{'current_balance'} )
+      ? @{ $params->{'current_balance'} }
+      :  ( $params->{'current_balance'} );
 
-  push @where, map { s/current_balance/$balance_sql/; $_ }
-                   @{ $params->{'current_balance'} };
+    push @where, map { s/current_balance/$balance_sql/; $_ }
+                     @current_balance;
+
+  }
 
   ##
   # custbatch
@@ -8228,13 +7777,13 @@ sub search_sql {
 
 }
 
-=item email_search_sql HASHREF
+=item email_search_result HASHREF
 
 (Class method)
 
 Emails a notice to the specified customers.
 
-Valid parameters are those of the L<search_sql> method, plus the following:
+Valid parameters are those of the L<search> method, plus the following:
 
 =over 4
 
@@ -8268,7 +7817,7 @@ retrying everything.
 
 =cut
 
-sub email_search_sql {
+sub email_search_result {
   my($class, $params) = @_;
 
   my $from = delete $params->{from};
@@ -8281,7 +7830,7 @@ sub email_search_sql {
   $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
     unless ref($params->{'payby'});
 
-  my $sql_query = $class->search_sql($params);
+  my $sql_query = $class->search($params);
 
   my $count_query   = delete($sql_query->{'count_query'});
   my $count_sth = dbh->prepare($count_query)
@@ -8333,7 +7882,7 @@ sub email_search_sql {
 use Storable qw(thaw);
 use Data::Dumper;
 use MIME::Base64;
-sub process_email_search_sql {
+sub process_email_search_result {
   my $job = shift;
   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
 
@@ -8345,7 +7894,7 @@ sub process_email_search_sql {
   $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
     unless ref($param->{'payby'});
 
-  my $error = FS::cust_main->email_search_sql( $param );
+  my $error = FS::cust_main->email_search_result( $param );
   die $error if $error;
 
 }
@@ -8353,8 +7902,8 @@ sub process_email_search_sql {
 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
-records.  Currently, I<first>, I<last> and/or I<company> may be specified (the
-appropriate ship_ field is also searched).
+records.  Currently, I<first>, I<last>, I<company> and/or I<address1> may be
+specified (the appropriate ship_ field is also searched).
 
 Additional options are the same as FS::Record::qsearch
 
@@ -8483,15 +8032,18 @@ sub smart_search {
   } 
 
   if ( $search =~ /^\s*(\d+)\s*$/
-            || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
-                 && $search =~ /^\s*(\w\w?\d+)\s*$/
-               )
-          )
+         || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
+              && $search =~ /^\s*(\w\w?\d+)\s*$/
+            )
+         || ( $conf->exists('address1-search' )
+              && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
+            )
+     )
   {
 
     my $num = $1;
 
-    if ( $num <= 2147483647 ) { #need a bigint custnum?  wow.
+    if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { 'custnum' => $num, %options },
@@ -8505,23 +8057,42 @@ sub smart_search {
       'extra_sql' => " AND $agentnums_sql", #agent virtualization
     } );
 
+    if ( $conf->exists('address1-search') ) {
+      my $len = length($num);
+      $num = lc($num);
+      foreach my $prefix ( '', 'ship_' ) {
+        push @cust_main, qsearch( {
+          'table'     => 'cust_main',
+          'hashref'   => { %options, },
+          'extra_sql' => 
+            ( keys(%options) ? ' AND ' : ' WHERE ' ).
+            " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
+            " AND $agentnums_sql",
+        } );
+      }
+    }
+
   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
 
     my($company, $last, $first) = ( $1, $2, $3 );
 
     # "Company (Last, First)"
     #this is probably something a browser remembered,
-    #so just do an exact search
+    #so just do an exact search (but case-insensitive, so USPS standardization
+    #doesn't throw a wrench in the works)
 
     foreach my $prefix ( '', 'ship_' ) {
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
-        'hashref'   => { $prefix.'first'   => $first,
-                         $prefix.'last'    => $last,
-                         $prefix.'company' => $company,
-                         %options,
-                       },
-        'extra_sql' => " AND $agentnums_sql",
+        'hashref'   => { %options },
+        'extra_sql' => 
+          ( keys(%options) ? ' AND ' : ' WHERE ' ).
+          join(' AND ',
+            " LOWER(${prefix}first)   = ". dbh->quote(lc($first)),
+            " LOWER(${prefix}last)    = ". dbh->quote(lc($last)),
+            " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
+            $agentnums_sql,
+          ),
       } );
     }
 
@@ -8580,11 +8151,16 @@ sub smart_search {
 
     #exact
     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-    $sql .= " (    LOWER(last)         = $q_value
-                OR LOWER(company)      = $q_value
-                OR LOWER(ship_last)    = $q_value
-                OR LOWER(ship_company) = $q_value
-              )";
+    $sql .= " (    LOWER(last)          = $q_value
+                OR LOWER(company)       = $q_value
+                OR LOWER(ship_last)     = $q_value
+                OR LOWER(ship_company)  = $q_value
+            ";
+    $sql .= "   OR LOWER(address1)      = $q_value
+                OR LOWER(ship_address1) = $q_value
+            "
+      if $conf->exists('address1-search');
+    $sql .= " )";
 
     push @cust_main, qsearch( {
       'table'     => 'cust_main',
@@ -8597,7 +8173,7 @@ sub smart_search {
     #getting complaints searches are not returning enough
     unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
 
-      #still some false laziness w/search_sql (was search/cust_main.cgi)
+      #still some false laziness w/search (was search/cust_main.cgi)
 
       #substring
 
@@ -8625,6 +8201,13 @@ sub smart_search {
         ;
       }
 
+      if ( $conf->exists('address1-search') ) {
+        push @hashrefs,
+          { 'address1'      => { op=>'ILIKE', value=>"%$value%" }, },
+          { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
+        ;
+      }
+
       foreach my $hashref ( @hashrefs ) {
 
         push @cust_main, qsearch( {
@@ -8655,6 +8238,10 @@ sub smart_search {
         push @cust_main,
           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
       }
+      if ( $conf->exists('address1-search') ) {
+        push @cust_main,
+          FS::cust_main->fuzzy_search( { 'address1' => $value }, @fuzopts );
+      }
 
     }
 
@@ -8738,9 +8325,6 @@ sub email_search {
 
 =cut
 
-use vars qw(@fuzzyfields);
-@fuzzyfields = ( 'last', 'first', 'company' );
-
 sub check_and_rebuild_fuzzyfiles {
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
   rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
@@ -8800,7 +8384,7 @@ sub all_X {
   \@array;
 }
 
-=item append_fuzzyfiles LASTNAME COMPANY
+=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
 
 =cut
 
@@ -8813,7 +8397,7 @@ sub append_fuzzyfiles {
 
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
 
-  foreach my $field (qw( first last company )) {
+  foreach my $field (@fuzzyfields) {
     my $value = shift;
 
     if ( $value ) {
@@ -9165,14 +8749,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({
@@ -9238,6 +8815,9 @@ 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;
+  $class->_upgrade_otaker(%opts);
+
 }
 
 =back