new tax rating engine
[freeside.git] / FS / FS / cust_main.pm
index b2b0a9b..ceefeaf 100644 (file)
@@ -1,5 +1,6 @@
 package FS::cust_main;
 
 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 strict;
 use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
              $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
@@ -7,13 +8,9 @@ use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Safe;
 use Carp;
 use Exporter;
 use Safe;
 use Carp;
 use Exporter;
-BEGIN {
-  eval "use Time::Local;";
-  die "Time::Local minimum version 1.05 required with Perl versions before 5.6"
-    if $] < 5.006 && !defined($Time::Local::VERSION);
-  #eval "use Time::Local qw(timelocal timelocal_nocheck);";
-  eval "use Time::Local qw(timelocal_nocheck);";
-}
+use Time::Local qw(timelocal_nocheck);
+use Data::Dumper;
+use Tie::IxHash;
 use Digest::MD5 qw(md5_base64);
 use Date::Format;
 use Date::Parse;
 use Digest::MD5 qw(md5_base64);
 use Date::Format;
 use Date::Parse;
@@ -22,7 +19,7 @@ use String::Approx qw(amatch);
 use Business::CreditCard 0.28;
 use Locale::Country;
 use Data::Dumper;
 use Business::CreditCard 0.28;
 use Locale::Country;
 use Data::Dumper;
-use FS::UID qw( getotaker dbh );
+use FS::UID qw( getotaker dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef );
 use FS::Misc qw( send_email generate_ps do_print );
 use FS::Msgcat qw(gettext);
 use FS::Record qw( qsearchs qsearch dbdef );
 use FS::Misc qw( send_email generate_ps do_print );
 use FS::Msgcat qw(gettext);
@@ -31,7 +28,9 @@ use FS::cust_svc;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_pay;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_pay;
+use FS::cust_pay_pending;
 use FS::cust_pay_void;
 use FS::cust_pay_void;
+use FS::cust_pay_batch;
 use FS::cust_credit;
 use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_credit;
 use FS::cust_refund;
 use FS::part_referral;
@@ -43,15 +42,15 @@ use FS::cust_bill_pay;
 use FS::prepay_credit;
 use FS::queue;
 use FS::part_pkg;
 use FS::prepay_credit;
 use FS::queue;
 use FS::part_pkg;
-use FS::part_bill_event qw(due_events);
-use FS::cust_bill_event;
-use FS::cust_tax_exempt;
-use FS::cust_tax_exempt_pkg;
+use FS::part_event;
+use FS::part_event_condition;
+#use FS::cust_event;
 use FS::type_pkgs;
 use FS::payment_gateway;
 use FS::agent_payment_gateway;
 use FS::banned_pay;
 use FS::payinfo_Mixin;
 use FS::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::Record FS::payinfo_Mixin );
 
 
 @ISA = qw( FS::Record FS::payinfo_Mixin );
 
@@ -350,7 +349,8 @@ sub insert {
     $error = $self->check_invoicing_list( $invoicing_list );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
     $error = $self->check_invoicing_list( $invoicing_list );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "checking invoicing_list (transaction rolled back): $error";
+      #return "checking invoicing_list (transaction rolled back): $error";
+      return $error;
     }
     $self->invoicing_list( $invoicing_list );
   }
     }
     $self->invoicing_list( $invoicing_list );
   }
@@ -1274,58 +1274,75 @@ sub check {
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
-  my @addfields = qw(
-    last first company address1 address2 city county state zip
-    country daytime night fax
-  );
+  if ( $conf->exists('cust_main-require_phone')
+       && ! length($self->daytime) && ! length($self->night)
+     ) {
 
 
-  if ( defined $self->dbdef_table->column('ship_last') ) {
-    if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
-                       @addfields )
-         && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
-       )
-    {
-      my $error =
-        $self->ut_name('ship_last')
-        || $self->ut_name('ship_first')
-        || $self->ut_textn('ship_company')
-        || $self->ut_text('ship_address1')
-        || $self->ut_textn('ship_address2')
-        || $self->ut_text('ship_city')
-        || $self->ut_textn('ship_county')
-        || $self->ut_textn('ship_state')
-        || $self->ut_country('ship_country')
-      ;
-      return $error if $error;
+    my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
+                          ? 'Day Phone'
+                          : FS::Msgcat::_gettext('daytime');
+    my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
+                        ? 'Night Phone'
+                        : FS::Msgcat::_gettext('night');
+  
+    return "$daytime_label or $night_label is required"
+  
+  }
 
 
-      #false laziness with above
-      unless ( qsearchs('cust_main_county', {
-        'country' => $self->ship_country,
-        'state'   => '',
-       } ) ) {
-        return "Unknown ship_state/ship_county/ship_country: ".
-          $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
-          unless qsearch('cust_main_county',{
-            'state'   => $self->ship_state,
-            'county'  => $self->ship_county,
-            'country' => $self->ship_country,
-          } );
-      }
-      #eofalse
-
-      $error =
-        $self->ut_phonen('ship_daytime', $self->ship_country)
-        || $self->ut_phonen('ship_night', $self->ship_country)
-        || $self->ut_phonen('ship_fax', $self->ship_country)
-        || $self->ut_zip('ship_zip', $self->ship_country)
-      ;
-      return $error if $error;
+  if ( $self->has_ship_address
+       && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
+                        $self->addr_fields )
+     )
+  {
+    my $error =
+      $self->ut_name('ship_last')
+      || $self->ut_name('ship_first')
+      || $self->ut_textn('ship_company')
+      || $self->ut_text('ship_address1')
+      || $self->ut_textn('ship_address2')
+      || $self->ut_text('ship_city')
+      || $self->ut_textn('ship_county')
+      || $self->ut_textn('ship_state')
+      || $self->ut_country('ship_country')
+    ;
+    return $error if $error;
 
 
-    } else { # ship_ info eq billing info, so don't store dup info in database
-      $self->setfield("ship_$_", '')
-        foreach qw( last first company address1 address2 city county state zip
-                    country daytime night fax );
+    #false laziness with above
+    unless ( qsearchs('cust_main_county', {
+      'country' => $self->ship_country,
+      'state'   => '',
+     } ) ) {
+      return "Unknown ship_state/ship_county/ship_country: ".
+        $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
+        unless qsearch('cust_main_county',{
+          'state'   => $self->ship_state,
+          'county'  => $self->ship_county,
+          'country' => $self->ship_country,
+        } );
     }
     }
+    #eofalse
+
+    $error =
+      $self->ut_phonen('ship_daytime', $self->ship_country)
+      || $self->ut_phonen('ship_night', $self->ship_country)
+      || $self->ut_phonen('ship_fax', $self->ship_country)
+      || $self->ut_zip('ship_zip', $self->ship_country)
+    ;
+    return $error if $error;
+
+    return "Unit # is required."
+      if $self->ship_address2 =~ /^\s*$/
+      && $conf->exists('cust_main-require_address2');
+
+  } else { # ship_ info eq billing info, so don't store dup info in database
+
+    $self->setfield("ship_$_", '')
+      foreach $self->addr_fields;
+
+    return "Unit # is required."
+      if $self->address2 =~ /^\s*$/
+      && $conf->exists('cust_main-require_address2');
+
   }
 
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
   }
 
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
@@ -1526,6 +1543,30 @@ sub check {
   $self->SUPER::check;
 }
 
   $self->SUPER::check;
 }
 
+=item addr_fields 
+
+Returns a list of fields which have ship_ duplicates.
+
+=cut
+
+sub addr_fields {
+  qw( last first company
+      address1 address2 city county state zip country
+      daytime night fax
+    );
+}
+
+=item has_ship_address
+
+Returns true if this customer record has a separate shipping address.
+
+=cut
+
+sub has_ship_address {
+  my $self = shift;
+  scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
+}
+
 =item all_pkgs
 
 Returns all packages (see L<FS::cust_pkg>) for this customer.
 =item all_pkgs
 
 Returns all packages (see L<FS::cust_pkg>) for this customer.
@@ -1547,6 +1588,16 @@ sub all_pkgs {
   sort sort_packages @cust_pkg;
 }
 
   sort sort_packages @cust_pkg;
 }
 
+=item cust_pkg
+
+Synonym for B<all_pkgs>.
+
+=cut
+
+sub cust_pkg {
+  shift->all_pkgs(@_);
+}
+
 =item ncancelled_pkgs
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
 =item ncancelled_pkgs
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -1561,11 +1612,18 @@ sub ncancelled_pkgs {
   my @cust_pkg = ();
   if ( $self->{'_pkgnum'} ) {
 
   my @cust_pkg = ();
   if ( $self->{'_pkgnum'} ) {
 
+    warn "$me ncancelled_pkgs: returning cached objects"
+      if $DEBUG > 1;
+
     @cust_pkg = grep { ! $_->getfield('cancel') }
                 values %{ $self->{'_pkgnum'}->cache };
 
   } else {
 
     @cust_pkg = grep { ! $_->getfield('cancel') }
                 values %{ $self->{'_pkgnum'}->cache };
 
   } else {
 
+    warn "$me ncancelled_pkgs: searching for packages with custnum ".
+         $self->custnum. "\n"
+      if $DEBUG > 1;
+
     @cust_pkg =
       qsearch( 'cust_pkg', {
                              'custnum' => $self->custnum,
     @cust_pkg =
       qsearch( 'cust_pkg', {
                              'custnum' => $self->custnum,
@@ -1648,7 +1706,8 @@ sub num_ncancelled_pkgs {
 }
 
 sub num_pkgs {
 }
 
 sub num_pkgs {
-  my( $self, $sql ) = @_;
+  my( $self ) = shift;
+  my $sql = scalar(@_) ? shift : '';
   $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
   my $sth = dbh->prepare(
     "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
   $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
   my $sth = dbh->prepare(
     "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
@@ -1683,10 +1742,20 @@ sub suspend {
   grep { $_->suspend(@_) } $self->unsuspended_pkgs;
 }
 
   grep { $_->suspend(@_) } $self->unsuspended_pkgs;
 }
 
-=item suspend_if_pkgpart PKGPART [ , PKGPART ... ]
+=item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
 
 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
 
 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
-PKGPARTs (see L<FS::part_pkg>).
+PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref instead
+of a list of pkgparts; the hashref has the following keys:
+
+=over 4
+
+=item pkgparts - listref of pkgparts
+
+=item (other options are passed to the suspend method)
+
+=back
+
 
 Returns a list: an empty list on success or a list of errors.
 
 
 Returns a list: an empty list on success or a list of errors.
 
@@ -1706,10 +1775,19 @@ sub suspend_if_pkgpart {
       $self->unsuspended_pkgs;
 }
 
       $self->unsuspended_pkgs;
 }
 
-=item suspend_unless_pkgpart PKGPART [ , PKGPART ... ]
+=item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
 
 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
 
 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
-listed PKGPARTs (see L<FS::part_pkg>).
+given PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref
+instead of a list of pkgparts; the hashref has the following keys:
+
+=over 4
+
+=item pkgparts - listref of pkgparts
+
+=item (other options are passed to the suspend method)
+
+=back
 
 Returns a list: an empty list on success or a list of errors.
 
 
 Returns a list: an empty list on success or a list of errors.
 
@@ -1733,22 +1811,31 @@ sub suspend_unless_pkgpart {
 
 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
 
 
 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
 
-Available options are: I<quiet>, I<reasonnum>, and I<ban>
+Available options are:
 
 
-I<quiet> can be set true to supress email cancellation notices.
+=over 4
+
+=item quiet - can be set true to supress email cancellation notices.
+
+=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
 
 
-# I<reasonnum> can be set to a cancellation reason (see L<FS::cancel_reason>)
+=item ban - can be set true to ban this customer's credit card or ACH information, if present.
 
 
-I<ban> can be set true to ban this customer's credit card or ACH information,
-if present.
+=back
 
 Always returns a list: an empty list on success or a list of errors.
 
 =cut
 
 sub cancel {
 
 Always returns a list: an empty list on success or a list of errors.
 
 =cut
 
 sub cancel {
-  my $self = shift;
-  my %opt = @_;
+  my( $self, %opt ) = @_;
+
+  warn "$me cancel called on customer ". $self->custnum. " with options ".
+       join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
+    if $DEBUG;
+
+  return ( 'access denied' )
+    unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
   if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
 
 
   if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
 
@@ -1763,7 +1850,13 @@ sub cancel {
 
   }
 
 
   }
 
-  grep { $_ } map { $_->cancel(@_) } $self->ncancelled_pkgs;
+  my @pkgs = $self->ncancelled_pkgs;
+
+  warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
+       scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
+    if $DEBUG;
+
+  grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
 }
 
 sub _banned_pay_hashref {
 }
 
 sub _banned_pay_hashref {
@@ -1810,10 +1903,99 @@ sub agent {
   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
 }
 
   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
 }
 
+=item bill_and_collect 
+
+Cancels and suspends any packages due, generates bills, applies payments and
+cred
+
+Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
+
+Options are passed as name-value pairs.  Currently available options are:
+
+=over 4
+
+=item time
+
+Bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
+
+ use Date::Parse;
+ ...
+ $cust_main->bill( 'time' => str2time('April 20th, 2001') );
+
+=item invoice_time
+
+Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item resetup
+
+If set true, re-charges setup fees.
+
+=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)
+
+=back
+
+=cut
+
+sub bill_and_collect {
+  my( $self, %options ) = @_;
+
+  ###
+  # cancel packages
+  ###
+
+  #$^T not $options{time} because freeside-daily -d is for pre-printing invoices
+  foreach my $cust_pkg (
+    grep { $_->expire && $_->expire <= $^T } $self->ncancelled_pkgs
+  ) {
+    my $error = $cust_pkg->cancel;
+    warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
+         " for custnum ". $self->custnum. ": $error"
+      if $error;
+  }
+
+  ###
+  # suspend packages
+  ###
+
+  #$^T not $options{time} because freeside-daily -d is for pre-printing invoices
+  foreach my $cust_pkg (
+    grep { (    $_->part_pkg->is_prepaid && $_->bill && $_->bill < $^T
+             || $_->adjourn && $_->adjourn <= $^T
+           )
+           && ! $_->susp
+         }
+         $self->ncancelled_pkgs
+  ) {
+    my $error = $cust_pkg->suspend;
+    warn "Error suspending package ". $cust_pkg->pkgnum.
+         " for custnum ". $self->custnum. ": $error"
+      if $error;
+  }
+
+  ###
+  # bill and collect
+  ###
+
+  my $error = $self->bill( %options );
+  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
+
+  $self->apply_payments_and_credits;
+
+  $error = $self->collect( %options );
+  warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
+
+}
+
 =item bill OPTIONS
 
 Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
 =item bill OPTIONS
 
 Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
-conjunction with the collect method.
+conjunction with the collect method by calling B<bill_and_collect>.
 
 If there is an error, returns the error, otherwise returns false.
 
 
 If there is an error, returns the error, otherwise returns false.
 
@@ -1821,15 +2003,27 @@ Options are passed as name-value pairs.  Currently available options are:
 
 =over 4
 
 
 =over 4
 
-=item resetup - if set true, re-charges setup fees.
+=item resetup
+
+If set true, re-charges setup fees.
+
+=item time
 
 
-=item time - bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
+Bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
 
  use Date::Parse;
  ...
  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
 
 
  use Date::Parse;
  ...
  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
 
-=item invoice_time - used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
+=item pkg_list
+
+An array ref of specific packages (objects) to attempt billing, instead trying all of them.
+
+ $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
+
+=item invoice_time
+
+Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
 
 =back
 
 
 =back
 
@@ -1882,6 +2076,7 @@ sub bill {
 
   my( $total_setup, $total_recur ) = ( 0, 0 );
   my %tax;
 
   my( $total_setup, $total_recur ) = ( 0, 0 );
   my %tax;
+  my %taxlisthash;
   my @precommit_hooks = ();
 
   foreach my $cust_pkg (
   my @precommit_hooks = ();
 
   foreach my $cust_pkg (
@@ -1940,6 +2135,13 @@ sub bill {
          ( $cust_pkg->getfield('bill') || 0 ) <= $time
     ) {
 
          ( $cust_pkg->getfield('bill') || 0 ) <= $time
     ) {
 
+      # XXX should this be a package event?  probably.  events are called
+      # at collection time at the moment, though...
+      if ( $part_pkg->can('reset_usage') ) {
+        warn "    resetting usage counters" if $DEBUG > 1;
+        $part_pkg->reset_usage($cust_pkg);
+      }
+
       warn "    bill recur\n" if $DEBUG > 1;
 
       # XXX shared with $recur_prog
       warn "    bill recur\n" if $DEBUG > 1;
 
       # XXX shared with $recur_prog
@@ -1963,8 +2165,7 @@ sub bill {
       # only for figuring next bill date, nothing else, so, reset $sdate again
       # here
       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
       # only for figuring next bill date, nothing else, so, reset $sdate again
       # here
       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
-      $cust_pkg->last_bill($sdate)
-        if $cust_pkg->dbdef_table->column('last_bill');
+      $cust_pkg->last_bill($sdate);
 
       if ( $part_pkg->freq =~ /^\d+$/ ) {
         $mon += $part_pkg->freq;
 
       if ( $part_pkg->freq =~ /^\d+$/ ) {
         $mon += $part_pkg->freq;
@@ -2045,140 +2246,94 @@ sub bill {
 
         unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
 
 
         unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
 
+          my @taxes = ();
+          my @taxoverrides = $part_pkg->part_pkg_taxoverride;
+          
           my $prefix = 
             ( $conf->exists('tax-ship_address') && length($self->ship_last) )
             ? 'ship_'
             : '';
           my $prefix = 
             ( $conf->exists('tax-ship_address') && length($self->ship_last) )
             ? 'ship_'
             : '';
-          my %taxhash = map { $_ => $self->get("$prefix$_") }
-                            qw( state county country );
 
 
-          $taxhash{'taxclass'} = $part_pkg->taxclass;
+          if ( $conf->exists('enable_taxproducts')
+               && (scalar(@taxoverrides) || $part_pkg->taxproductnum )
+             )
+          { 
 
 
-          my @taxes = qsearch( 'cust_main_county', \%taxhash );
+            my @taxclassnums = ();
+            my $geocode = $self->geocode('cch');
 
 
-          unless ( @taxes ) {
-            $taxhash{'taxclass'} = '';
-            @taxes =  qsearch( 'cust_main_county', \%taxhash );
-          }
+            if ( scalar( @taxoverrides ) ) {
+              @taxclassnums = map { $_->taxclassnum } @taxoverrides;
+            }elsif ( $part_pkg->taxproductnum ) {
+              @taxclassnums = map { $_->taxclassnum }
+                              $part_pkg->part_pkg_taxrate('cch', $geocode);
+            }
 
 
-          #one more try at a whole-country tax rate
-          unless ( @taxes ) {
-            $taxhash{$_} = '' foreach qw( state county );
-            @taxes =  qsearch( 'cust_main_county', \%taxhash );
-          }
+            my $extra_sql =
+              "AND (".
+              join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
+
+            @taxes = qsearch({ 'table' => 'tax_rate',
+                               'hashref' => { 'geocode' => $geocode, },
+                               'extra_sql' => $extra_sql,
+                            })
+              if scalar(@taxclassnums);
+
+
+          }else{
+
+            my %taxhash = map { $_ => $self->get("$prefix$_") }
+                              qw( state county country );
+
+            $taxhash{'taxclass'} = $part_pkg->taxclass;
+
+            @taxes = qsearch( 'cust_main_county', \%taxhash );
+
+            unless ( @taxes ) {
+              $taxhash{'taxclass'} = '';
+              @taxes =  qsearch( 'cust_main_county', \%taxhash );
+            }
+
+            #one more try at a whole-country tax rate
+            unless ( @taxes ) {
+              $taxhash{$_} = '' foreach qw( state county );
+              @taxes =  qsearch( 'cust_main_county', \%taxhash );
+            }
+
+          } #if $conf->exists('enable_taxproducts') 
 
           # maybe eliminate this entirely, along with all the 0% records
           unless ( @taxes ) {
             $dbh->rollback if $oldAutoCommit;
 
           # maybe eliminate this entirely, along with all the 0% records
           unless ( @taxes ) {
             $dbh->rollback if $oldAutoCommit;
-            return
-              "fatal: can't find tax rate for state/county/country/taxclass ".
-              join('/', ( map $self->get("$prefix$_"),
-                              qw(state county country)
-                        ),
-                        $part_pkg->taxclass ). "\n";
+            my $error;
+            if ( $conf->exists('enable_taxproducts') ) { 
+              $error = 
+                "fatal: can't find tax rate for zip/taxproduct/pkgpart ".
+                join('/', ( map $self->get("$prefix$_"),
+                                qw(zip)
+                          ),
+                          $part_pkg->taxproduct_description,
+                          $part_pkg->pkgpart ). "\n";
+            }else{
+              $error = 
+                "fatal: can't find tax rate for state/county/country/taxclass ".
+                join('/', ( map $self->get("$prefix$_"),
+                                qw(state county country)
+                          ),
+                          $part_pkg->taxclass ). "\n";
+            }
+            return $error;
           }
   
           foreach my $tax ( @taxes ) {
           }
   
           foreach my $tax ( @taxes ) {
+            my $taxname = ref( $tax ). ' '. $tax->taxnum;
+            if ( exists( $taxlisthash{ $taxname } ) ) {
+              push @{ $taxlisthash{ $taxname  } }, $cust_bill_pkg;
+            }else{
+              $taxlisthash{ $taxname } = [ $tax, $cust_bill_pkg ];
+            }
+          }
 
 
-            my $taxable_charged = 0;
-            $taxable_charged += $setup
-              unless $part_pkg->setuptax =~ /^Y$/i
-                  || $tax->setuptax =~ /^Y$/i;
-            $taxable_charged += $recur
-              unless $part_pkg->recurtax =~ /^Y$/i
-                  || $tax->recurtax =~ /^Y$/i;
-            next unless $taxable_charged;
-
-            if ( $tax->exempt_amount && $tax->exempt_amount > 0 ) {
-              #my ($mon,$year) = (localtime($sdate) )[4,5];
-              my ($mon,$year) = (localtime( $sdate || $cust_bill->_date ) )[4,5];
-              $mon++;
-              my $freq = $part_pkg->freq || 1;
-              if ( $freq !~ /(\d+)$/ ) {
-                $dbh->rollback if $oldAutoCommit;
-                return "daily/weekly package definitions not (yet?)".
-                       " compatible with monthly tax exemptions";
-              }
-              my $taxable_per_month =
-                sprintf("%.2f", $taxable_charged / $freq );
-
-              #call the whole thing off if this customer has any old
-              #exemption records...
-              my @cust_tax_exempt =
-                qsearch( 'cust_tax_exempt' => { custnum=> $self->custnum } );
-              if ( @cust_tax_exempt ) {
-                $dbh->rollback if $oldAutoCommit;
-                return
-                  'this customer still has old-style tax exemption records; '.
-                  'run bin/fs-migrate-cust_tax_exempt?';
-              }
-
-              foreach my $which_month ( 1 .. $freq ) {
-
-                #maintain the new exemption table now
-                my $sql = "
-                  SELECT SUM(amount)
-                    FROM cust_tax_exempt_pkg
-                      LEFT JOIN cust_bill_pkg USING ( billpkgnum )
-                      LEFT JOIN cust_bill     USING ( invnum     )
-                    WHERE custnum = ?
-                      AND taxnum  = ?
-                      AND year    = ?
-                      AND month   = ?
-                ";
-                my $sth = dbh->prepare($sql) or do {
-                  $dbh->rollback if $oldAutoCommit;
-                  return "fatal: can't lookup exising exemption: ". dbh->errstr;
-                };
-                $sth->execute(
-                  $self->custnum,
-                  $tax->taxnum,
-                  1900+$year,
-                  $mon,
-                ) or do {
-                  $dbh->rollback if $oldAutoCommit;
-                  return "fatal: can't lookup exising exemption: ". dbh->errstr;
-                };
-                my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
-                
-                my $remaining_exemption =
-                  $tax->exempt_amount - $existing_exemption;
-                if ( $remaining_exemption > 0 ) {
-                  my $addl = $remaining_exemption > $taxable_per_month
-                    ? $taxable_per_month
-                    : $remaining_exemption;
-                  $taxable_charged -= $addl;
-
-                  my $cust_tax_exempt_pkg = new FS::cust_tax_exempt_pkg ( {
-                    'billpkgnum' => $cust_bill_pkg->billpkgnum,
-                    'taxnum'     => $tax->taxnum,
-                    'year'       => 1900+$year,
-                    'month'      => $mon,
-                    'amount'     => sprintf("%.2f", $addl ),
-                  } );
-                  $error = $cust_tax_exempt_pkg->insert;
-                  if ( $error ) {
-                    $dbh->rollback if $oldAutoCommit;
-                    return "fatal: can't insert cust_tax_exempt_pkg: $error";
-                  }
-                } # if $remaining_exemption > 0
-
-                #++
-                $mon++;
-                #until ( $mon < 12 ) { $mon -= 12; $year++; }
-                until ( $mon < 13 ) { $mon -= 12; $year++; }
-  
-              } #foreach $which_month
-  
-            } #if $tax->exempt_amount
-
-            $taxable_charged = sprintf( "%.2f", $taxable_charged);
-
-            #$tax += $taxable_charged * $cust_main_county->tax / 100
-            $tax{ $tax->taxname || 'Tax' } +=
-              $taxable_charged * $tax->tax / 100
-
-          } #foreach my $tax ( @taxes )
 
         } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
 
 
         } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
 
@@ -2190,12 +2345,36 @@ sub bill {
 
   unless ( $cust_bill->cust_bill_pkg ) {
     $cust_bill->delete; #don't create an invoice w/o line items
 
   unless ( $cust_bill->cust_bill_pkg ) {
     $cust_bill->delete; #don't create an invoice w/o line items
+
+   # XXX this seems to be broken
+   #( DBD::Pg::st execute failed: ERROR:  syntax error at or near "hcb" )
+#   # get rid of our fake history too, waste of unecessary space
+#    my $h_cleanup_query = q{
+#      DELETE FROM h_cust_bill hcb
+#       WHERE hcb.invnum = ?
+#      AND NOT EXISTS ( SELECT 1 FROM cust_bill cb where cb.invnum = hcb.invnum )
+#    };
+#    my $h_sth = $dbh->prepare($h_cleanup_query);
+#    $h_sth->execute($invnum);
+
     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
     return '';
   }
 
   my $charged = sprintf( "%.2f", $total_setup + $total_recur );
 
     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
     return '';
   }
 
   my $charged = sprintf( "%.2f", $total_setup + $total_recur );
 
+  foreach my $tax ( keys %taxlisthash ) {
+    my $tax_object = shift @{ $taxlisthash{$tax} };
+    my $listref_or_error = $tax_object->taxline( @{ $taxlisthash{$tax} } );
+    unless (ref($listref_or_error)) {
+      $dbh->rollback if $oldAutoCommit;
+      return $listref_or_error;
+    }
+
+    $tax{ $listref_or_error->[0] } += $listref_or_error->[1];
+  
+  }
+
   foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
     my $tax = sprintf("%.2f", $tax{$taxname} );
     $charged = sprintf( "%.2f", $charged+$tax );
   foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
     my $tax = sprintf("%.2f", $tax{$taxname} );
     $charged = sprintf( "%.2f", $charged+$tax );
@@ -2244,12 +2423,9 @@ sub bill {
 (Attempt to) collect money for this customer's outstanding invoices (see
 L<FS::cust_bill>).  Usually used after the bill method.
 
 (Attempt to) collect money for this customer's outstanding invoices (see
 L<FS::cust_bill>).  Usually used after the bill method.
 
-Depending on the value of `payby', this may print or email an invoice (I<BILL>,
-I<DCRD>, or I<DCHK>), charge a credit card (I<CARD>), charge via electronic
-check/ACH (I<CHEK>), or just add any necessary (pseudo-)payment (I<COMP>).
-
-Most actions are now triggered by invoice events; see L<FS::part_bill_event>
-and the invoice events web interface.
+Actions are now triggered by billing events; see L<FS::part_event> and the
+billing events web interface.  Old-style invoice events (see
+L<FS::part_bill_event>) have been deprecated.
 
 If there is an error, returns the error, otherwise returns false.
 
 
 If there is an error, returns the error, otherwise returns false.
 
@@ -2257,19 +2433,34 @@ Options are passed as name-value pairs.
 
 Currently available options are:
 
 
 Currently available options are:
 
-invoice_time - Use this time when deciding when to print invoices and
-late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse>
-for conversion functions.
+=over 4
+
+=item invoice_time
+
+Use this time when deciding when to print invoices and late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=item retry
+
+Retry card/echeck/LEC transactions even when not scheduled by invoice events.
+
+=item quiet
+
+set true to surpress email card/ACH decline notices.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
 
 
-retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
-events.
+=item payby
 
 
-quiet - set true to surpress email card/ACH decline notices.
+allows for one time override of normal customer billing method
 
 
-freq - "1d" for the traditional, daily events (the default), or "1m" for the
-new monthly events
+=item debug
 
 
-payby - allows for one time override of normal customer billing method
+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)
+
+
+=back
 
 =cut
 
 
 =cut
 
@@ -2291,12 +2482,9 @@ sub collect {
 
   $self->select_for_update; #mutex
 
 
   $self->select_for_update; #mutex
 
-  my $balance = $self->balance;
-  warn "$me collect customer ". $self->custnum. ": balance $balance\n"
-    if $DEBUG;
-  unless ( $balance > 0 ) { #redundant?????
-    $dbh->rollback if $oldAutoCommit; #hmm
-    return '';
+  if ( $DEBUG ) {
+    my $balance = $self->balance;
+    warn "$me collect customer ". $self->custnum. ": balance $balance\n"
   }
 
   if ( exists($options{'retry_card'}) ) {
   }
 
   if ( exists($options{'retry_card'}) ) {
@@ -2311,51 +2499,257 @@ sub collect {
     }
   }
 
     }
   }
 
-  my $extra_sql = '';
-  if ( defined $options{'freq'} && $options{'freq'} eq '1m' ) {
-    $extra_sql = " AND freq = '1m' ";
-  } else {
-    $extra_sql = " AND ( freq = '1d' OR freq IS NULL OR freq = '' ) ";
-  }
-
-  foreach my $cust_bill ( $self->open_cust_bill ) {
-
-    # don't try to charge for the same invoice if it's already in a batch
-    #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
+  # false laziness w/pay_batch::import_results
 
 
-    last if $self->balance <= 0;
-
-    warn "  invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")\n"
-      if $DEBUG > 1;
+  my $due_cust_event = $self->due_cust_event(
+    'debug'      => ( $options{'debug'} || 0 ),
+    'time'       => $invoice_time,
+    'check_freq' => $options{'check_freq'},
+  );
+  unless( ref($due_cust_event) ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $due_cust_event;
+  }
 
 
-    foreach my $part_bill_event ( due_events ( $cust_bill,
-                                               exists($options{'payby'}) 
-                                                ? $options{'payby'}
-                                                : $self->payby,
-                                              $invoice_time,
-                                              $extra_sql ) ) {
+  foreach my $cust_event ( @$due_cust_event ) {
 
 
-      last if $cust_bill->owed <= 0  # don't run subsequent events if owed<=0
-           || $self->balance   <= 0; # or if balance<=0
+    #XXX lock event
+    
+    #re-eval event conditions (a previous event could have changed things)
+    unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
+      #don't leave stray "new/locked" records around
+      my $error = $cust_event->delete;
+      if ( $error ) {
+        #gah, even with transactions
+        $dbh->commit if $oldAutoCommit; #well.
+        return $error;
+      }
+      next;
+    }
 
 
-      {
-        local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
-        warn "  do_event " .  $cust_bill . " ". (%options) .  "\n"
-          if $DEBUG > 1;
+    {
+      local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
+      warn "  running cust_event ". $cust_event->eventnum. "\n"
+        if $DEBUG > 1;
 
 
-        if (my $error = $part_bill_event->do_event($cust_bill, %options)) {
+      
+      #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
+      if ( my $error = $cust_event->do_event() ) {
+        #XXX wtf is this?  figure out a proper dealio with return value
+        #from do_event
          # gah, even with transactions.
          $dbh->commit if $oldAutoCommit; #well.
          return $error;
        }
          # gah, even with transactions.
          $dbh->commit if $oldAutoCommit; #well.
          return $error;
        }
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=item due_cust_event [ HASHREF | OPTION => VALUE ... ]
+
+Inserts database records for and returns an ordered listref of new events due
+for this customer, as FS::cust_event objects (see L<FS::cust_event>).  If no
+events are due, an empty listref is returned.  If there is an error, returns a
+scalar error message.
+
+To actually run the events, call each event's test_condition method, and if
+still true, call the event's do_event method.
+
+Options are passed as a hashref or as a list of name-value pairs.  Available
+options are:
+
+=over 4
+
+=item check_freq
+
+Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
+
+=item time
+
+"Current time" for the events.
+
+=item debug
+
+Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+
+=item eventtable
+
+Only return events for the specified eventtable (by default, events of all eventtables are returned)
+
+=item objects
+
+Explicitly pass the objects to be tested (typically used with eventtable).
+
+=back
+
+=cut
+
+sub due_cust_event {
+  my $self = shift;
+  my %opt = ref($_[0]) ? %{ $_[0] } : @_;
+
+  #???
+  #my $DEBUG = $opt{'debug'}
+  local($DEBUG) = $opt{'debug'}
+    if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
+
+  warn "$me due_cust_event called with options ".
+       join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
+    if $DEBUG;
+
+  $opt{'time'} ||= time;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->select_for_update; #mutex
+
+  ###
+  # 1: find possible events (initial search)
+  ###
+  
+  my @cust_event = ();
+
+  my @eventtable = $opt{'eventtable'}
+                     ? ( $opt{'eventtable'} )
+                     : FS::part_event->eventtables_runorder;
+
+  foreach my $eventtable ( @eventtable ) {
+
+    my @objects;
+    if ( $opt{'objects'} ) {
+
+      @objects = @{ $opt{'objects'} };
+
+    } else {
+
+      #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
+      @objects = ( $eventtable eq 'cust_main' )
+                   ? ( $self )
+                   : ( $self->$eventtable() );
+
+    }
+
+    my @e_cust_event = ();
+
+    my $cross = "CROSS JOIN $eventtable";
+    $cross .= ' LEFT JOIN cust_main USING ( custnum )'
+      unless $eventtable eq 'cust_main';
+
+    foreach my $object ( @objects ) {
+
+      #this first search uses the condition_sql magic for optimization.
+      #the more possible events we can eliminate in this step the better
+
+      my $cross_where = '';
+      my $pkey = $object->primary_key;
+      $cross_where = "$eventtable.$pkey = ". $object->$pkey();
+
+      my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
+      my $extra_sql =
+        FS::part_event_condition->where_conditions_sql( $eventtable,
+                                                        'time'=>$opt{'time'}
+                                                      );
+      my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
+
+      $extra_sql = "AND $extra_sql" if $extra_sql;
+
+      #here is the agent virtualization
+      $extra_sql .= " AND (    part_event.agentnum IS NULL
+                            OR part_event.agentnum = ". $self->agentnum. ' )';
+
+      $extra_sql .= " $order";
+
+      warn "searching for events for $eventtable ". $object->$pkey. "\n"
+        if $opt{'debug'} > 2;
+      my @part_event = qsearch( {
+        'debug'     => ( $opt{'debug'} > 3 ? 1 : 0 ),
+        'select'    => 'part_event.*',
+        'table'     => 'part_event',
+        'addl_from' => "$cross $join",
+        'hashref'   => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
+                         'eventtable' => $eventtable,
+                         'disabled'   => '',
+                       },
+        'extra_sql' => "AND $cross_where $extra_sql",
+      } );
+
+      if ( $DEBUG > 2 ) {
+        my $pkey = $object->primary_key;
+        warn "      ". scalar(@part_event).
+             " possible events found for $eventtable ". $object->$pkey(). "\n";
       }
 
       }
 
+      push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
+
     }
 
     }
 
+    warn "    ". scalar(@e_cust_event).
+         " subtotal possible cust events found for $eventtable\n"
+      if $DEBUG > 1;
+
+    push @cust_event, @e_cust_event;
+
+  }
+
+  warn "  ". scalar(@cust_event).
+       " total possible cust events found in initial search\n"
+    if $DEBUG; # > 1;
+
+  ##
+  # 2: test conditions
+  ##
+  
+  my %unsat = ();
+
+  @cust_event = grep $_->test_conditions( 'time'          => $opt{'time'},
+                                          'stats_hashref' => \%unsat ),
+                     @cust_event;
+
+  warn "  ". scalar(@cust_event). " cust events left satisfying conditions\n"
+    if $DEBUG; # > 1;
+
+  warn "    invalid conditions not eliminated with condition_sql:\n".
+       join('', map "      $_: ".$unsat{$_}."\n", keys %unsat )
+    if $DEBUG; # > 1;
+
+  ##
+  # 3: insert
+  ##
+
+  foreach my $cust_event ( @cust_event ) {
+
+    my $error = $cust_event->insert();
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+                                       
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+
+  ##
+  # 4: return
+  ##
+
+  warn "  returning events: ". Dumper(@cust_event). "\n"
+    if $DEBUG > 2;
+
+  \@cust_event;
 
 }
 
 
 }
 
@@ -2366,9 +2760,9 @@ events for for retry.  Useful if card information has changed or manual
 retry is desired.  The 'collect' method must be called to actually retry
 the transaction.
 
 retry is desired.  The 'collect' method must be called to actually retry
 the transaction.
 
-Implementation details: For each of this customer's open invoices, changes
-the status of the first "done" (with statustext error) realtime processing
-event to "failed".
+Implementation details: For either this customer, or for each of this
+customer's open invoices, changes the status of the first "done" (with
+statustext error) realtime processing event to "failed".
 
 =cut
 
 
 =cut
 
@@ -2386,25 +2780,60 @@ sub retry_realtime {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  foreach my $cust_bill (
-    grep { $_->cust_bill_event }
-      $self->open_cust_bill
-  ) {
-    my @cust_bill_event =
-      sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
-        grep {
-               #$_->part_bill_event->plan eq 'realtime-card'
-               $_->part_bill_event->eventcode =~
-                   /\$cust_bill\->(batch|realtime)_(card|ach|lec)/
-                 && $_->status eq 'done'
-                 && $_->statustext
-             }
-          $cust_bill->cust_bill_event;
-    next unless @cust_bill_event;
-    my $error = $cust_bill_event[0]->retry;
+  #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;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error scheduling invoice event for retry: $error";
+      return "error scheduling event for retry: $error";
     }
 
   }
     }
 
   }
@@ -2422,7 +2851,7 @@ L<http://420.am/business-onlinepayment> for supported gateways.
 
 Available methods are: I<CC>, I<ECHECK> and I<LEC>
 
 
 Available methods are: I<CC>, I<ECHECK> and I<LEC>
 
-Available options are: I<description>, I<invnum>, I<quiet>
+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,
 
 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,
@@ -2437,6 +2866,11 @@ call the B<apply_payments> method.
 
 I<quiet> can be set true to surpress email decline notices.
 
 
 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
 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
 
 =cut
@@ -2450,6 +2884,8 @@ sub realtime_bop {
 
   $options{'description'} ||= 'Internet services';
 
 
   $options{'description'} ||= 'Internet services';
 
+  return $self->fake_bop($method, $amount, %options) if $options{'fake'};
+
   eval "use Business::OnlinePayment";  
   die $@ if $@;
 
   eval "use Business::OnlinePayment";  
   die $@ if $@;
 
@@ -2457,6 +2893,22 @@ sub realtime_bop {
                   ? $options{'payinfo'}
                   : $self->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;
+
   ###
   # select a gateway
   ###
   ###
   # select a gateway
   ###
@@ -2567,6 +3019,10 @@ sub realtime_bop {
   $content{invoice_number} = $options{'invnum'}
     if exists($options{'invnum'}) && length($options{'invnum'});
 
   $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' ) { 
 
   my $paydate = '';
   if ( $method eq 'CC' ) { 
 
@@ -2580,7 +3036,7 @@ sub realtime_bop {
     my $paycvv = exists($options{'paycvv'})
                    ? $options{'paycvv'}
                    : $self->paycvv;
     my $paycvv = exists($options{'paycvv'})
                    ? $options{'paycvv'}
                    : $self->paycvv;
-    $content{cvv2} = $self->paycvv
+    $content{cvv2} = $paycvv
       if length($paycvv);
 
     my $paystart_month = exists($options{'paystart_month'})
       if length($paycvv);
 
     my $paystart_month = exists($options{'paystart_month'})
@@ -2639,6 +3095,49 @@ sub realtime_bop {
   # run transaction(s)
   ###
 
   # 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,
+    '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 );
   my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
 
   my $transaction = new Business::OnlinePayment( $processor, @bop_options );
@@ -2672,9 +3171,33 @@ sub realtime_bop {
     'phone'          => $self->daytime || $self->night,
     %content, #after
   );
     'phone'          => $self->daytime || $self->night,
     %content, #after
   );
-  $transaction->submit();
+
+  $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 ) {
 
   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 $auth = $transaction->authorization;
     my $ordernum = $transaction->can('order_number')
                    ? $transaction->order_number
@@ -2716,6 +3239,10 @@ sub realtime_bop {
 
   }
 
 
   }
 
+  $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
   ###
   ###
   # remove paycvv after initial transaction
   ###
@@ -2738,12 +3265,6 @@ sub realtime_bop {
 
   if ( $transaction->is_success() ) {
 
 
   if ( $transaction->is_success() ) {
 
-    my %method2payby = (
-      'CC'     => 'CARD',
-      'ECHECK' => 'CHEK',
-      'LEC'    => 'LECB',
-    );
-
     my $paybatch = '';
     if ( $payment_gateway ) { # agent override
       $paybatch = $payment_gateway->gatewaynum. '-';
     my $paybatch = '';
     if ( $payment_gateway ) { # agent override
       $paybatch = $payment_gateway->gatewaynum. '-';
@@ -2759,13 +3280,21 @@ sub realtime_bop {
        'custnum'  => $self->custnum,
        'invnum'   => $options{'invnum'},
        'paid'     => $amount,
        'custnum'  => $self->custnum,
        'invnum'   => $options{'invnum'},
        'paid'     => $amount,
-       '_date'     => '',
+       '_date'    => '',
        'payby'    => $method2payby{$method},
        'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
        'paydate'  => $paydate,
     } );
        'payby'    => $method2payby{$method},
        'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
        'paydate'  => $paydate,
     } );
-    $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
+    #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 ) : () );
 
 
     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
 
@@ -2775,16 +3304,41 @@ sub realtime_bop {
                                       ( 'manual' => 1 ) : ()
                                     );
       if ( $error2 ) {
                                       ( 'manual' => 1 ) : ()
                                     );
       if ( $error2 ) {
-        # gah, even with transactions.
-        my $e = 'WARNING: Card/ACH debited but database not updated - '.
+        # 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 inserting payment ($processor): $error2".
                 " (previously tried insert with invnum #$options{'invnum'}" .
-                ": $error )";
+                ": $error ) - pending payment saved as paypendingnum ".
+                $cust_pay_pending->paypendingnum. "\n";
         warn $e;
         return $e;
       }
     }
         warn $e;
         return $e;
       }
     }
-    return ''; #no error
+
+    if ( $options{'paynum_ref'} ) {
+      ${ $options{'paynum_ref'} } = $cust_pay->paynum;
+    }
+
+    $cust_pay_pending->status('done');
+    $cust_pay_pending->statustext('captured');
+    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 {
 
 
   } else {
 
@@ -2841,14 +3395,95 @@ sub realtime_bop {
         'body'    => [ $template->fill_in(HASH => $templ_hash) ],
       );
 
         'body'    => [ $template->fill_in(HASH => $templ_hash) ],
       );
 
-      $perror .= " (also received error sending decline notification: $error)"
-        if $error;
+      $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;
+  }
+
+}
+
+=item fake_bop
+
+=cut
+
+sub fake_bop {
+  my( $self, $method, $amount, %options ) = @_;
+
+  if ( $options{'fake_failure'} ) {
+     return "Error: No error; test failure requested with fake_failure";
+  }
+
+  my %method2payby = (
+    'CC'     => 'CARD',
+    'ECHECK' => 'CHEK',
+    'LEC'    => 'LECB',
+  );
+
+  #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 $paybatch = 'FakeProcessor:54:32';
+
+  my $cust_pay = new FS::cust_pay ( {
+     'custnum'  => $self->custnum,
+     'invnum'   => $options{'invnum'},
+     'paid'     => $amount,
+     '_date'    => '',
+     'payby'    => $method2payby{$method},
+     #'payinfo'  => $payinfo,
+     'payinfo'  => '4111111111111111',
+     'paybatch' => $paybatch,
+     #'paydate'  => $paydate,
+     'paydate'  => '2012-05-01',
+  } );
+  $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
 
 
+  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, even with transactions.
+      my $e = 'WARNING: Card/ACH debited but database not updated - '.
+              "error inserting (fake!) payment: $error2".
+              " (previously tried insert with invnum #$options{'invnum'}" .
+              ": $error )";
+      warn $e;
+      return $e;
     }
     }
-  
-    return $perror;
   }
 
   }
 
+  if ( $options{'paynum_ref'} ) {
+    ${ $options{'paynum_ref'} } = $cust_pay->paynum;
+  }
+
+  return ''; #no error
+
 }
 
 =item default_payment_gateway
 }
 
 =item default_payment_gateway
@@ -2864,7 +3499,7 @@ sub default_payment_gateway {
   #load up config
   my $bop_config = 'business-onlinepayment';
   $bop_config .= '-ach'
   #load up config
   my $bop_config = 'business-onlinepayment';
   $bop_config .= '-ach'
-    if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach');
+    if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
   my ( $processor, $login, $password, $action, @bop_options ) =
     $conf->config($bop_config);
   $action ||= 'normal authorization';
   my ( $processor, $login, $password, $action, @bop_options ) =
     $conf->config($bop_config);
   $action ||= 'normal authorization';
@@ -3245,6 +3880,8 @@ sub batch_card {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  #this needs to handle mysql as well as Pg, like svc_acct.pm
+  #(make it into a common function if folks need to do batching with mysql)
   $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
     or return "Cannot lock pay_batch: " . $dbh->errstr;
 
   $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
     or return "Cannot lock pay_batch: " . $dbh->errstr;
 
@@ -3378,15 +4015,37 @@ Applies unapplied payments and credits.
 In most cases, this new method should be used in place of sequential
 apply_payments and apply_credits methods.
 
 In most cases, this new method should be used in place of sequential
 apply_payments and apply_credits methods.
 
+If there is an error, returns the error, otherwise returns false.
+
 =cut
 
 sub apply_payments_and_credits {
   my $self = shift;
 
 =cut
 
 sub apply_payments_and_credits {
   my $self = shift;
 
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->select_for_update; #mutex
+
   foreach my $cust_bill ( $self->open_cust_bill ) {
   foreach my $cust_bill ( $self->open_cust_bill ) {
-    $cust_bill->apply_payments_and_credits;
+    my $error = $cust_bill->apply_payments_and_credits;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error applying: $error";
+    }
   }
 
   }
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
+
 }
 
 =item apply_credits OPTION => VALUE ...
 }
 
 =item apply_credits OPTION => VALUE ...
@@ -3397,13 +4056,31 @@ chronological order if the I<order> option is set to B<newest>) and returns the
 value of any remaining unapplied credits available for refund (see
 L<FS::cust_refund>).
 
 value of any remaining unapplied credits available for refund (see
 L<FS::cust_refund>).
 
+Dies if there is an error.
+
 =cut
 
 sub apply_credits {
   my $self = shift;
   my %opt = @_;
 
 =cut
 
 sub apply_credits {
   my $self = shift;
   my %opt = @_;
 
-  return 0 unless $self->total_credited;
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->select_for_update; #mutex
+
+  unless ( $self->total_credited ) {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return 0;
+  }
 
   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
 
   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
@@ -3432,13 +4109,20 @@ sub apply_credits {
       'amount'  => $amount,
     } );
     my $error = $cust_credit_bill->insert;
       'amount'  => $amount,
     } );
     my $error = $cust_credit_bill->insert;
-    die $error if $error;
+    if ( $error ) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      die $error;
+    }
     
     redo if ($cust_bill->owed > 0);
 
   }
 
     
     redo if ($cust_bill->owed > 0);
 
   }
 
-  return $self->total_credited;
+  my $total_credited = $self->total_credited;
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return $total_credited;
 }
 
 =item apply_payments
 }
 
 =item apply_payments
@@ -3448,11 +4132,26 @@ to outstanding invoice balances in chronological order.
 
  #and returns the value of any remaining unapplied payments.
 
 
  #and returns the value of any remaining unapplied payments.
 
+Dies if there is an error.
+
 =cut
 
 sub apply_payments {
   my $self = shift;
 
 =cut
 
 sub apply_payments {
   my $self = shift;
 
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->select_for_update; #mutex
+
   #return 0 unless
 
   my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
   #return 0 unless
 
   my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
@@ -3482,13 +4181,20 @@ sub apply_payments {
       'amount' => $amount,
     } );
     my $error = $cust_bill_pay->insert;
       'amount' => $amount,
     } );
     my $error = $cust_bill_pay->insert;
-    die $error if $error;
+    if ( $error ) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      die $error;
+    }
 
     redo if ( $cust_bill->owed > 0);
 
   }
 
 
     redo if ( $cust_bill->owed > 0);
 
   }
 
-  return $self->total_unapplied_payments;
+  my $total_unapplied_payments = $self->total_unapplied_payments;
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return $total_unapplied_payments;
 }
 
 =item total_credited
 }
 
 =item total_credited
@@ -3527,17 +4233,38 @@ sub total_unapplied_payments {
   sprintf( "%.2f", $total_unapplied );
 }
 
   sprintf( "%.2f", $total_unapplied );
 }
 
+=item total_unapplied_refunds
+
+Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
+customer.  See L<FS::cust_refund/unapplied>.
+
+=cut
+
+sub total_unapplied_refunds {
+  my $self = shift;
+  my $total_unapplied = 0;
+  foreach my $cust_refund ( qsearch('cust_refund', {
+    'custnum' => $self->custnum,
+  } ) ) {
+    $total_unapplied += $cust_refund->unapplied;
+  }
+  sprintf( "%.2f", $total_unapplied );
+}
+
 =item balance
 
 =item balance
 
-Returns the balance for this customer (total_owed minus total_credited
-minus total_unapplied_payments).
+Returns the balance for this customer (total_owed plus total_unrefunded, minus
+total_credited minus total_unapplied_payments).
 
 =cut
 
 sub balance {
   my $self = shift;
   sprintf( "%.2f",
 
 =cut
 
 sub balance {
   my $self = shift;
   sprintf( "%.2f",
-    $self->total_owed - $self->total_credited - $self->total_unapplied_payments
+      $self->total_owed
+    + $self->total_unapplied_refunds
+    - $self->total_credited
+    - $self->total_unapplied_payments
   );
 }
 
   );
 }
 
@@ -3555,7 +4282,8 @@ sub balance_date {
   my $self = shift;
   my $time = shift;
   sprintf( "%.2f",
   my $self = shift;
   my $time = shift;
   sprintf( "%.2f",
-    $self->total_owed_date($time)
+        $self->total_owed_date($time)
+      + $self->total_unapplied_refunds
       - $self->total_credited
       - $self->total_unapplied_payments
   );
       - $self->total_credited
       - $self->total_unapplied_payments
   );
@@ -3673,7 +4401,8 @@ is an error, returns the error, otherwise returns false.
 
 sub check_invoicing_list {
   my( $self, $arrayref ) = @_;
 
 sub check_invoicing_list {
   my( $self, $arrayref ) = @_;
-  foreach my $address ( @{$arrayref} ) {
+
+  foreach my $address ( @$arrayref ) {
 
     if ($address eq 'FAX' and $self->getfield('fax') eq '') {
       return 'Can\'t add FAX invoice destination with a blank FAX number.';
 
     if ($address eq 'FAX' and $self->getfield('fax') eq '') {
       return 'Can\'t add FAX invoice destination with a blank FAX number.';
@@ -3688,7 +4417,13 @@ sub check_invoicing_list {
                 : $cust_main_invoice->checkdest
     ;
     return $error if $error;
                 : $cust_main_invoice->checkdest
     ;
     return $error if $error;
+
   }
   }
+
+  return "Email address required"
+    if $conf->exists('cust_main-require_invoicing_list_email')
+    && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
+
   '';
 }
 
   '';
 }
 
@@ -3845,13 +4580,13 @@ otherwise returns false.
 =cut
 
 sub credit {
 =cut
 
 sub credit {
-  my( $self, $amount, $reason ) = @_;
+  my( $self, $amount, $reason, %options ) = @_;
   my $cust_credit = new FS::cust_credit {
     'custnum' => $self->custnum,
     'amount'  => $amount,
     'reason'  => $reason,
   };
   my $cust_credit = new FS::cust_credit {
     'custnum' => $self->custnum,
     'amount'  => $amount,
     'reason'  => $reason,
   };
-  $cust_credit->insert;
+  $cust_credit->insert(%options);
 }
 
 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
 }
 
 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
@@ -3863,13 +4598,14 @@ the error, otherwise returns false.
 
 sub charge {
   my $self = shift;
 
 sub charge {
   my $self = shift;
-  my ( $amount, $pkg, $comment, $taxclass, $additional );
+  my ( $amount, $pkg, $comment, $taxclass, $additional, $classnum );
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
+    $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
     $additional = $_[0]->{additional};
   }else{
     $amount     = shift;
     $additional = $_[0]->{additional};
   }else{
     $amount     = shift;
@@ -3896,6 +4632,7 @@ sub charge {
     'plan'     => 'flat',
     'freq'     => 0,
     'disabled' => 'Y',
     'plan'     => 'flat',
     'freq'     => 0,
     'disabled' => 'Y',
+    'classnum' => $classnum ? $classnum : '',
     'taxclass' => $taxclass,
   } );
 
     'taxclass' => $taxclass,
   } );
 
@@ -3999,6 +4736,17 @@ sub cust_pay_void {
     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
 }
 
     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
 }
 
+=item cust_pay_batch
+
+Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
+
+=cut
+
+sub cust_pay_batch {
+  my $self = shift;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
+}
 
 =item cust_refund
 
 
 =item cust_refund
 
@@ -4079,6 +4827,40 @@ sub country_full {
   code2country($self->country);
 }
 
   code2country($self->country);
 }
 
+=item geocode DATA_PROVIDER
+
+Returns a value for the customer location as encoded by DATA_PROVIDER.
+Currently this only makes sense for "CCH" as DATA_PROVIDER.
+
+=cut
+
+sub geocode {
+  my ($self, $data_provider) = (shift, shift);  #always cch for now
+
+  my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
+               ? 'ship_'
+               : '';
+
+  my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
+    if $self->country eq 'US';
+
+  #CCH specific location stuff
+  my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
+
+  my $geocode = '';
+  my $cust_tax_location =
+    qsearchs( {
+                'table'     => 'cust_tax_location', 
+                'hashref'   => { 'zip' => $zip, 'data_provider' => $data_provider },
+                'extra_sql' => $extra_sql,
+              }
+            );
+  $geocode = $cust_tax_location->geocode
+    if $cust_tax_location;
+
+  $geocode;
+}
+
 =item cust_status
 
 =item status
 =item cust_status
 
 =item status
@@ -4137,13 +4919,13 @@ Returns a hex triplet color string for this customer's status.
 =cut
 
 use vars qw(%statuscolor);
 =cut
 
 use vars qw(%statuscolor);
-%statuscolor = (
+tie my %statuscolor, 'Tie::IxHash',
   'prospect'  => '7e0079', #'000000', #black?  naw, purple
   'active'    => '00CC00', #green
   'inactive'  => '0000CC', #blue
   'suspended' => 'FF9900', #yellow
   'cancelled' => 'FF0000', #red
   'prospect'  => '7e0079', #'000000', #black?  naw, purple
   'active'    => '00CC00', #green
   'inactive'  => '0000CC', #blue
   'suspended' => 'FF9900', #yellow
   'cancelled' => 'FF0000', #red
-);
+;
 
 sub statuscolor { shift->cust_statuscolor(@_); }
 
 
 sub statuscolor { shift->cust_statuscolor(@_); }
 
@@ -4152,12 +4934,72 @@ sub cust_statuscolor {
   $statuscolor{$self->cust_status};
 }
 
   $statuscolor{$self->cust_status};
 }
 
+=item tickets
+
+Returns an array of hashes representing the customer's RT tickets.
+
+=cut
+
+sub tickets {
+  my $self = shift;
+
+  my $num = $conf->config('cust_main-max_tickets') || 10;
+  my @tickets = ();
+
+  unless ( $conf->config('ticket_system-custom_priority_field') ) {
+
+    @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
+
+  } else {
+
+    foreach my $priority (
+      $conf->config('ticket_system-custom_priority_field-values'), ''
+    ) {
+      last if scalar(@tickets) >= $num;
+      push @tickets, 
+        @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                               $num - scalar(@tickets),
+                                               $priority,
+                                             )
+         };
+    }
+  }
+  (@tickets);
+}
+
+# Return services representing svc_accts in customer support packages
+sub support_services {
+  my $self = shift;
+  my %packages = map { $_ => 1 } $conf->config('support_packages');
+
+  grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
+    grep { $_->part_svc->svcdb eq 'svc_acct' }
+    map { $_->cust_svc }
+    grep { exists $packages{ $_->pkgpart } }
+    $self->ncancelled_pkgs;
+
+}
+
 =back
 
 =head1 CLASS METHODS
 
 =over 4
 
 =back
 
 =head1 CLASS METHODS
 
 =over 4
 
+=item statuses
+
+Class method that returns the list of possible status strings for customers
+(see L<the status method|/status>).  For example:
+
+  @statuses = FS::cust_main->statuses();
+
+=cut
+
+sub statuses {
+  #my $self = shift; #could be class...
+  keys %statuscolor;
+}
+
 =item prospect_sql
 
 Returns an SQL expression identifying prospective cust_main records (customers
 =item prospect_sql
 
 Returns an SQL expression identifying prospective cust_main records (customers
@@ -4260,6 +5102,106 @@ sub uncancel_sql { "
   )
 "; }
 
   )
 "; }
 
+=item balance_sql
+
+Returns an SQL fragment to retreive the balance.
+
+=cut
+
+sub balance_sql { "
+    ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
+        WHERE cust_bill.custnum   = cust_main.custnum     )
+  - ( SELECT COALESCE( SUM(paid),    0 ) FROM cust_pay
+        WHERE cust_pay.custnum    = cust_main.custnum     )
+  - ( SELECT COALESCE( SUM(amount),  0 ) FROM cust_credit
+        WHERE cust_credit.custnum = cust_main.custnum     )
+  + ( SELECT COALESCE( SUM(refund),  0 ) FROM cust_refund
+        WHERE cust_refund.custnum = cust_main.custnum     )
+"; }
+
+=item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Returns an SQL fragment to retreive the balance for this customer, only
+considering invoices with date earlier than START_TIME, and optionally not
+later than END_TIME (total_owed_date minus total_credited 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)
+
+=item total - set to true to remove all customer comparison clauses, for totals
+
+=item where - WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
+
+=item join - JOIN clause (typically used with the total option)
+
+=item 
+
+=back
+
+=cut
+
+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 $j = $opt{'join'} || '';
+
+  my $owed_wh   = $class->_money_table_where( 'cust_bill',   $start,$end,%opt );
+  my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
+  my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
+  my $pay_wh    = $class->_money_table_where( 'cust_pay',    $start,$end,%opt );
+
+  "   ( SELECT COALESCE(SUM($owed),         0) FROM cust_bill   $j $owed_wh   )
+    + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
+    - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
+    - ( SELECT COALESCE(SUM($unapp_pay),    0) FROM cust_pay    $j $pay_wh    )
+  ";
+
+}
+
+=item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Helper method for balance_date_sql; name (and usage) subject to change
+(suggestions welcome).
+
+Returns a WHERE clause for the specified monetary TABLE (cust_bill,
+cust_refund, cust_credit or cust_pay).
+
+If TABLE is "cust_bill" or the unapplied_date option is true, only
+considers records with date earlier than START_TIME, and optionally not
+later than END_TIME .
+
+=cut
+
+sub _money_table_where {
+  my( $class, $table, $start, $end, %opt ) = @_;
+
+  my @where = ();
+  push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
+  if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
+    push @where, "$table._date <= $start" if defined($start) && length($start);
+    push @where, "$table._date >  $end"   if defined($end)   && length($end);
+  }
+  push @where, @{$opt{'where'}} if $opt{'where'};
+  my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
+
+  $where;
+
+}
+
 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
@@ -4388,7 +5330,14 @@ sub smart_search {
 
     }
 
 
     }
 
-  } elsif ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search
+  # custnum search (also try agent_custid), with some tweaking options if your
+  # legacy cust "numbers" have letters
+  } elsif ( $search =~ /^\s*(\d+)\s*$/
+            || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
+                 && $search =~ /^\s*(\w\w?\d+)\s*$/
+               )
+          )
+  {
 
     push @cust_main, qsearch( {
       'table'     => 'cust_main',
 
     push @cust_main, qsearch( {
       'table'     => 'cust_main',
@@ -4396,6 +5345,12 @@ sub smart_search {
       'extra_sql' => " AND $agentnums_sql", #agent virtualization
     } );
 
       'extra_sql' => " AND $agentnums_sql", #agent virtualization
     } );
 
+    push @cust_main, qsearch( {
+      'table'     => 'cust_main',
+      'hashref'   => { 'agent_custid' => $1, %options },
+      'extra_sql' => " AND $agentnums_sql", #agent virtualization
+    } );
+
   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
 
     my($company, $last, $first) = ( $1, $2, $3 );
   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
 
     my($company, $last, $first) = ( $1, $2, $3 );
@@ -4483,9 +5438,10 @@ sub smart_search {
       'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
     } );
 
       'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
     } );
 
-    #always do substring & fuzzy,
-    #getting complains searches are not returning enough
-    unless ( @cust_main && $skip_fuzzy ) {  #no exact match, trying substring/fuzzy
+    #no exact match, trying substring/fuzzy
+    #always do substring & fuzzy (unless they're explicity config'ed off)
+    #getting complaints searches are not returning enough
+    unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
 
       #still some false laziness w/ search/cust_main.cgi
 
 
       #still some false laziness w/ search/cust_main.cgi
 
@@ -4558,6 +5514,72 @@ sub smart_search {
 
 }
 
 
 }
 
+=item email_search
+
+Accepts the following options: I<email>, the email address to search for.  The
+email address will be searched for as an email invoice destination and as an
+svc_acct account.
+
+#Any additional options are treated as an additional qualifier on the search
+#(i.e. I<agentnum>).
+
+Returns a (possibly empty) array of FS::cust_main objects (but usually just
+none or one).
+
+=cut
+
+sub email_search {
+  my %options = @_;
+
+  local($DEBUG) = 1;
+
+  my $email = delete $options{'email'};
+
+  #we're only being used by RT at the moment... no agent virtualization yet
+  #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+  my @cust_main = ();
+
+  if ( $email =~ /([^@]+)\@([^@]+)/ ) {
+
+    my ( $user, $domain ) = ( $1, $2 );
+
+    warn "$me smart_search: searching for $user in domain $domain"
+      if $DEBUG;
+
+    push @cust_main,
+      map $_->cust_main,
+          qsearch( {
+                     'table'     => 'cust_main_invoice',
+                     'hashref'   => { 'dest' => $email },
+                   }
+                 );
+
+    push @cust_main,
+      map  $_->cust_main,
+      grep $_,
+      map  $_->cust_svc->cust_pkg,
+          qsearch( {
+                     'table'     => 'svc_acct',
+                     'hashref'   => { 'username' => $user, },
+                     'extra_sql' =>
+                       'AND ( SELECT domain FROM svc_domain
+                                WHERE svc_acct.domsvc = svc_domain.svcnum
+                            ) = '. dbh->quote($domain),
+                   }
+                 );
+  }
+
+  my %saw = ();
+  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
+  warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
+    if $DEBUG;
+
+  @cust_main;
+
+}
+
 =item check_and_rebuild_fuzzyfiles
 
 =cut
 =item check_and_rebuild_fuzzyfiles
 
 =cut
@@ -4692,6 +5714,18 @@ sub batch_import {
                   svc_acct.username svc_acct._password 
                 );
     $payby = 'BILL';
                   svc_acct.username svc_acct._password 
                 );
     $payby = 'BILL';
+ } elsif ( $format eq 'extended-plus_company' ) {
+    @fields = qw( agent_custid refnum
+                  last first company address1 address2 city state zip country
+                  daytime night
+                  ship_last ship_first ship_company ship_address1 ship_address2
+                  ship_city ship_state ship_zip ship_country
+                  payinfo paycvv paydate
+                  invoicing_list
+                  cust_pkg.pkgpart
+                  svc_acct.username svc_acct._password 
+                );
+    $payby = 'BILL';
   } else {
     die "unknown format $format";
   }
   } else {
     die "unknown format $format";
   }
@@ -4805,7 +5839,7 @@ sub batch_import {
         my $part_pkg = $cust_pkg->part_pkg;
        unless ( $part_pkg ) {
          $dbh->rollback if $oldAutoCommit;
         my $part_pkg = $cust_pkg->part_pkg;
        unless ( $part_pkg ) {
          $dbh->rollback if $oldAutoCommit;
-         return "unknown pkgnum ". $cust_pkg{'pkgpart'};
+         return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
        } 
         $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' );
         push @svc_acct, new FS::svc_acct ( \%svc_acct )
        } 
         $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' );
         push @svc_acct, new FS::svc_acct ( \%svc_acct )
@@ -4830,8 +5864,12 @@ sub batch_import {
         return "can't bill customer for $line: $error";
       }
   
         return "can't bill customer for $line: $error";
       }
   
-      $cust_main->apply_payments_and_credits;
-  
+      $error = $cust_main->apply_payments_and_credits;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "can't bill customer for $line: $error";
+      }
+
       $error = $cust_main->collect();
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
       $error = $cust_main->collect();
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
@@ -4986,7 +6024,11 @@ sub notify {
   $notify_template->compile()
     or die "can't compile template: Text::Template::ERROR";
 
   $notify_template->compile()
     or die "can't compile template: Text::Template::ERROR";
 
-  my $paydate = $customer->paydate;
+  $FS::notify_template::_template::company_name = $conf->config('company_name');
+  $FS::notify_template::_template::company_address =
+    join("\n", $conf->config('company_address') ). "\n";
+
+  my $paydate = $customer->paydate || '2037-12-31';
   $FS::notify_template::_template::first = $customer->first;
   $FS::notify_template::_template::last = $customer->last;
   $FS::notify_template::_template::company = $customer->company;
   $FS::notify_template::_template::first = $customer->first;
   $FS::notify_template::_template::last = $customer->last;
   $FS::notify_template::_template::company = $customer->company;
@@ -5039,7 +6081,7 @@ I<$payby> - a description of the method of payment for the customer
             # would be nice to use FS::payby::shortname
 I<$payinfo> - the masked account information used to collect for this customer
 I<$expdate> - the expiration of the customer payment method in seconds from epoch
             # would be nice to use FS::payby::shortname
 I<$payinfo> - the masked account information used to collect for this customer
 I<$expdate> - the expiration of the customer payment method in seconds from epoch
-I<$returnaddress> - the return address defaults to invoice_latexreturnaddress
+I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
 
 =cut
 
 
 =cut
 
@@ -5061,7 +6103,9 @@ sub generate_letter {
   my %letter_data = map { $_ => $self->$_ } $self->fields;
   $letter_data{payinfo} = $self->mask_payinfo;
 
   my %letter_data = map { $_ => $self->$_ } $self->fields;
   $letter_data{payinfo} = $self->mask_payinfo;
 
-  my $paydate = $self->paydate;
+  #my $paydate = $self->paydate || '2037-12-31';
+  my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31';
+
   my $payby = $self->payby;
   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
   my $payby = $self->payby;
   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
@@ -5085,14 +6129,24 @@ sub generate_letter {
 
   unless(exists($letter_data{returnaddress})){
     my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
 
   unless(exists($letter_data{returnaddress})){
     my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
-                                                  $self->_agent_template)
+                                                  $self->agent_template)
                      );
                      );
-
-    $letter_data{returnaddress} = length($retadd) ? $retadd : '~';
+    if ( length($retadd) ) {
+      $letter_data{returnaddress} = $retadd;
+    } elsif ( grep /\S/, $conf->config('company_address') ) {
+      $letter_data{returnaddress} =
+        join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
+                          $conf->config('company_address')
+        );
+    } else {
+      $letter_data{returnaddress} = '~';
+    }
   }
 
   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
 
   }
 
   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
 
+  $letter_data{company_name} = $conf->config('company_name');
+
   my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
   my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
@@ -5154,32 +6208,73 @@ sub agent_invoice_from {
 sub _agent_plandata {
   my( $self, $option ) = @_;
 
 sub _agent_plandata {
   my( $self, $option ) = @_;
 
-  my $part_bill_event = qsearchs( 'part_bill_event',
-    {
-      'payby'     => $self->payby,
-      'plan'      => 'send_agent',
-      'plandata'  => { 'op'    => '~',
-                       'value' => "(^|\n)agentnum ".
-                                   '([0-9]*, )*'.
-                                  $self->agentnum.
-                                   '(, [0-9]*)*'.
-                                  "(\n|\$)",
-                     },
-    },
-    '',
-    'ORDER BY seconds LIMIT 1'
-  );
+  #yuck.  this whole thing needs to be reconciled better with 1.9's idea of
+  #agent-specific Conf
 
 
-  return '' unless $part_bill_event;
+  use FS::part_event::Condition;
+  
+  my $agentnum = $self->agentnum;
 
 
-  if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) {
-    return $1;
+  my $regexp = '';
+  if ( driver_name =~ /^Pg/i ) {
+    $regexp = '~';
+  } elsif ( driver_name =~ /^mysql/i ) {
+    $regexp = 'REGEXP';
   } else {
   } else {
-    warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart.
-         " plandata for $option";
+    die "don't know how to use regular expressions in ". driver_name. " databases";
+  }
+
+  my $part_event_option =
+    qsearchs({
+      'select'    => 'part_event_option.*',
+      'table'     => 'part_event_option',
+      'addl_from' => q{
+        LEFT JOIN part_event USING ( eventpart )
+        LEFT JOIN part_event_option AS peo_agentnum
+          ON ( part_event.eventpart = peo_agentnum.eventpart
+               AND peo_agentnum.optionname = 'agentnum'
+               AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
+             )
+        LEFT JOIN part_event_option AS peo_cust_bill_age
+          ON ( part_event.eventpart = peo_cust_bill_age.eventpart
+               AND peo_cust_bill_age.optionname = 'cust_bill_age'
+             )
+      },
+      #'hashref'   => { 'optionname' => $option },
+      #'hashref'   => { 'part_event_option.optionname' => $option },
+      'extra_sql' =>
+        " WHERE part_event_option.optionname = ". dbh->quote($option).
+        " AND action = 'cust_bill_send_agent' ".
+        " AND ( disabled IS NULL OR disabled != 'Y' ) ".
+        " AND peo_agentnum.optionname = 'agentnum' ".
+        " AND agentnum IS NULL OR agentnum = $agentnum ".
+        " ORDER BY
+           CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age'
+           THEN -1
+          ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue').
+        " END
+          , part_event.weight".
+        " LIMIT 1"
+    });
+    
+  unless ( $part_event_option ) {
+    return $self->agent->invoice_template || ''
+      if $option eq 'agent_templatename';
     return '';
   }
 
     return '';
   }
 
+  $part_event_option->optionvalue;
+
+}
+
+sub queued_bill {
+  ## actual sub, not a method, designed to be called from the queue.
+  ## sets up the customer, and calls the bill_and_collect
+  my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
+  my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
+      $cust_main->bill_and_collect(
+        %args,
+      );
 }
 
 =back
 }
 
 =back
@@ -5206,6 +6301,8 @@ Birthdates rely on negative epoch values.
 The payby for card/check batches is broken.  With mixed batching, bad
 things will happen.
 
 The payby for card/check batches is broken.  With mixed batching, bad
 things will happen.
 
+B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
+
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>