disable auto-billing of specific customer packages, RT#6378
[freeside.git] / FS / FS / cust_main.pm
index 256eb73..2574ca7 100644 (file)
@@ -2,13 +2,18 @@ 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 vars qw( @ISA @EXPORT_OK $DEBUG $me $conf
+             @encrypted_fields
+             $import $ignore_expired_card
+             $skip_fuzzyfiles @fuzzyfields
+             @paytypes
+           );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Safe;
 use Carp;
 use Exporter;
 use Scalar::Util qw( blessed );
+use List::Util qw( min );
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
@@ -30,6 +35,7 @@ use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill_pkg_tax_location;
+use FS::cust_bill_pkg_tax_rate_location;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -39,7 +45,11 @@ use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_location;
+use FS::cust_class;
+use FS::cust_main_exemption;
+use FS::cust_tax_adjustment;
 use FS::tax_rate;
+use FS::tax_rate_location;
 use FS::cust_tax_location;
 use FS::part_pkg_taxrate;
 use FS::agent;
@@ -72,10 +82,14 @@ $DEBUG = 0;
 $me = '[FS::cust_main]';
 
 $import = 0;
-$skip_fuzzyfiles = 0;
 $ignore_expired_card = 0;
 
+$skip_fuzzyfiles = 0;
+@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
+
 @encrypted_fields = ('payinfo', 'paycvv');
+sub nohistory_fields { ('paycvv'); }
+
 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
 
 #ask FS::UID to run this stuff for us later
@@ -359,7 +373,7 @@ invoicing_list destination to the newly-created svc_acct.  Here's an example:
 
   $cust_main->insert( {}, [ $email, 'POST' ] );
 
-Currently available options are: I<depend_jobnum> and I<noexport>.
+Currently available options are: I<depend_jobnum>, I<noexport> and I<tax_exemption>.
 
 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
 on the supplied jobnum (they will not run until the specific job completes).
@@ -370,6 +384,9 @@ The I<noexport> option is deprecated.  If I<noexport> is set true, no
 provisioning jobs (exports) are scheduled.  (You can schedule them later with
 the B<reexport> method.)
 
+The I<tax_exemption> option can be set to an arrayref of tax names.
+FS::cust_main_exemption records will be created and inserted.
+
 =cut
 
 sub insert {
@@ -455,6 +472,24 @@ sub insert {
     $self->invoicing_list( $invoicing_list );
   }
 
+  warn "  setting cust_main_exemption\n"
+    if $DEBUG > 1;
+
+  my $tax_exemption = delete $options{'tax_exemption'};
+  if ( $tax_exemption ) {
+    foreach my $taxname ( @$tax_exemption ) {
+      my $cust_main_exemption = new FS::cust_main_exemption {
+        'custnum' => $self->custnum,
+        'taxname' => $taxname,
+      };
+      my $error = $cust_main_exemption->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "inserting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+  }
+
   if (    $conf->config('cust_main-skeleton_tables')
        && $conf->config('cust_main-skeleton_custnum') ) {
 
@@ -705,6 +740,14 @@ jobs will have a dependancy on the supplied job (they will not run until the
 specific job completes).  This can be used to defer provisioning until some
 action completes (such as running the customer's credit card successfully).
 
+=item ticket_subject
+
+Optional subject for a ticket created and attached to this customer
+
+=item ticket_subject
+
+Optional queue name for ticket additions
+
 =back
 
 =cut
@@ -724,6 +767,9 @@ sub order_pkg {
   $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
     if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
 
+  my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
+                          qw( ticket_subject ticket_queue );
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -747,7 +793,7 @@ sub order_pkg {
 
   $cust_pkg->custnum( $self->custnum );
 
-  my $error = $cust_pkg->insert;
+  my $error = $cust_pkg->insert( %insert_params );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "inserting cust_pkg (transaction rolled back): $error";
@@ -1280,6 +1326,16 @@ sub delete {
     }
   }
 
+  foreach my $cust_main_exemption (
+    qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } )
+  ) {
+    my $error = $cust_main_exemption->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
@@ -1291,7 +1347,8 @@ sub delete {
 
 }
 
-=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ]
+=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
+
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
@@ -1303,6 +1360,11 @@ check_invoicing_list first.  Here's an example:
 
   $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
 
+Currently available options are: I<tax_exemption>.
+
+The I<tax_exemption> option can be set to an arrayref of tax names.
+FS::cust_main_exemption records will be deleted and inserted as appropriate.
+
 =cut
 
 sub replace {
@@ -1349,7 +1411,7 @@ sub replace {
     return $error;
   }
 
-  if ( @param ) { # INVOICING_LIST_ARYREF
+  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
     my $invoicing_list = shift @param;
     $error = $self->check_invoicing_list( $invoicing_list );
     if ( $error ) {
@@ -1359,6 +1421,40 @@ sub replace {
     $self->invoicing_list( $invoicing_list );
   }
 
+  my %options = @param;
+
+  my $tax_exemption = delete $options{'tax_exemption'};
+  if ( $tax_exemption ) {
+
+    my %cust_main_exemption =
+      map { $_->taxname => $_ }
+          qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
+
+    foreach my $taxname ( @$tax_exemption ) {
+
+      next if delete $cust_main_exemption{$taxname};
+
+      my $cust_main_exemption = new FS::cust_main_exemption {
+        'custnum' => $self->custnum,
+        'taxname' => $taxname,
+      };
+      my $error = $cust_main_exemption->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "inserting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+
+    foreach my $cust_main_exemption ( values %cust_main_exemption ) {
+      my $error = $cust_main_exemption->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "deleting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+
+  }
+
   if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
        grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
     # card/check/lec info has changed, want to retry realtime_ invoice events
@@ -1403,9 +1499,7 @@ sub queue_fuzzyfiles_update {
   my $dbh = dbh;
 
   my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-  my $error = $queue->insert( map $self->getfield($_),
-                                  qw(first last company)
-                            );
+  my $error = $queue->insert( map $self->getfield($_), @fuzzyfields );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "queueing job (transaction rolled back): $error";
@@ -1413,9 +1507,7 @@ sub queue_fuzzyfiles_update {
 
   if ( $self->ship_last ) {
     $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-    $error = $queue->insert( map $self->getfield("ship_$_"),
-                                 qw(first last company)
-                           );
+    $error = $queue->insert( map $self->getfield("ship_$_"), @fuzzyfields );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1446,6 +1538,7 @@ sub check {
     || $self->ut_number('agentnum')
     || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
+    || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
     || $self->ut_name('first')
@@ -1464,6 +1557,7 @@ sub check {
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
     || $self->ut_alphan('geocode')
+    || $self->ut_floatn('cdr_termination_percentage')
   ;
 
   #barf.  need message catalogs.  i18n.  etc.
@@ -1481,6 +1575,13 @@ sub check {
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
+  if ( $self->censustract ne '' ) {
+    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
+      or return "Illegal census tract: ". $self->censustract;
+    
+    $self->censustract("$1.$2");
+  }
+
   if ( $self->ss eq '' ) {
     $self->ss('');
   } else {
@@ -1748,6 +1849,8 @@ sub check {
     my( $m, $y );
     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
+    } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+      ( $m, $y ) = ( $2, "19$1" );
     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
       ( $m, $y ) = ( $3, "20$2" );
     } else {
@@ -1772,7 +1875,7 @@ sub check {
     $self->payname($1);
   }
 
-  foreach my $flag (qw( tax spool_cdr squelch_cdr )) {
+  foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
     $self->$flag($1);
   }
@@ -1809,6 +1912,25 @@ sub has_ship_address {
   scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
 }
 
+=item location_hash
+
+Returns a list of key/value pairs, with the following keys: address1, adddress2,
+city, county, state, zip, country.  The shipping address is used if present.
+
+=cut
+
+#geocode?  dependent on tax-ship_address config, not available in cust_location
+#mostly.  not yet then.
+
+sub location_hash {
+  my $self = shift;
+  my $prefix = $self->has_ship_address ? 'ship_' : '';
+
+  map { $_ => $self->get($prefix.$_) }
+      qw( address1 address2 city county state zip country geocode );
+      #fields that cust_location has
+}
+
 =item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all packages (see L<FS::cust_pkg>) for this customer.
@@ -1819,17 +1941,13 @@ sub all_pkgs {
   my $self = shift;
   my $extra_qsearch = ref($_[0]) ? shift : {};
 
-  return $self->num_pkgs unless wantarray; #XXX doesn't work w/$extra_qsearch
+  return $self->num_pkgs unless wantarray || keys(%$extra_qsearch);
 
   my @cust_pkg = ();
   if ( $self->{'_pkgnum'} ) {
     @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
   } else {
-    @cust_pkg = qsearch({
-      %$extra_qsearch,
-      'table'   => 'cust_pkg',
-      'hashref' => { 'custnum' => $self->custnum },
-    });
+    @cust_pkg = $self->_cust_pkg($extra_qsearch);
   }
 
   sort sort_packages @cust_pkg;
@@ -1856,6 +1974,61 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+
+Options are
+
+=over 4
+
+=item join_string
+
+used to separate the address elements (defaults to ', ')
+
+=item escape_function
+
+a callback used for escaping the text of the address elements
+
+=back
+
+=cut
+
+# false laziness with FS::cust_location::line
+
+sub location_label {
+  my $self = shift;
+  my %opt = @_;
+
+  my $separator = $opt{join_string} || ', ';
+  my $escape = $opt{escape_function} || sub{ shift };
+  my $line = '';
+  my $cydefault = FS::conf->new->config('countrydefault') || 'US';
+  my $prefix = length($self->ship_last) ? 'ship_' : '';
+
+  my $notfirst = 0;
+  foreach (qw ( address1 address2 ) ) {
+    my $method = "$prefix$_";
+    $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
+      if $self->$method;
+    $notfirst++;
+  }
+  $notfirst = 0;
+  foreach (qw ( city county state zip ) ) {
+    my $method = "$prefix$_";
+    if ( $self->$method ) {
+      $line .= ' (' if $method eq 'county';
+      $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
+      $line .= ' )' if $method eq 'county';
+      $notfirst++;
+    }
+  }
+  $line .= $separator. &$escape(code2country($self->country))
+    if $self->country ne $cydefault;
+
+  $line;
+}
+
 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -1885,11 +2058,7 @@ sub ncancelled_pkgs {
 
     $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) ';
 
-    @cust_pkg = qsearch({
-      %$extra_qsearch,
-      'table'     => 'cust_pkg',
-      'hashref'   => { 'custnum' => $self->custnum },
-    });
+    @cust_pkg = $self->_cust_pkg($extra_qsearch);
 
   }
 
@@ -1897,20 +2066,46 @@ sub ncancelled_pkgs {
 
 }
 
+sub _cust_pkg {
+  my $self = shift;
+  my $extra_qsearch = ref($_[0]) ? shift : {};
+
+  $extra_qsearch->{'select'} ||= '*';
+  $extra_qsearch->{'select'} .=
+   ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum )
+     AS _num_cust_svc';
+
+  map {
+        $_->{'_num_cust_svc'} = $_->get('_num_cust_svc');
+        $_;
+      }
+  qsearch({
+    %$extra_qsearch,
+    'table'   => 'cust_pkg',
+    'hashref' => { 'custnum' => $self->custnum },
+  });
+
+}
+
 # This should be generalized to use config options to determine order.
 sub sort_packages {
   
+  my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
+  return $locationsort if $locationsort;
+
   if ( $a->get('cancel') xor $b->get('cancel') ) {
     return -1 if $b->get('cancel');
     return  1 if $a->get('cancel');
     #shouldn't get here...
     return 0;
   } else {
+    my $a_num_cust_svc = $a->num_cust_svc;
+    my $b_num_cust_svc = $b->num_cust_svc;
+    return 0  if !$a_num_cust_svc && !$b_num_cust_svc;
+    return -1 if  $a_num_cust_svc && !$b_num_cust_svc;
+    return 1  if !$a_num_cust_svc &&  $b_num_cust_svc;
     my @a_cust_svc = $a->cust_svc;
     my @b_cust_svc = $b->cust_svc;
-    return 0  if !scalar(@a_cust_svc) && !scalar(@b_cust_svc);
-    return -1 if  scalar(@a_cust_svc) && !scalar(@b_cust_svc);
-    return 1  if !scalar(@a_cust_svc) &&  scalar(@b_cust_svc);
     $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
   }
 
@@ -1953,6 +2148,18 @@ sub unsuspended_pkgs {
   grep { ! $_->susp } $self->ncancelled_pkgs;
 }
 
+=item next_bill_date
+
+Returns the next date this customer will be billed, as a UNIX timestamp, or
+undef if no active package has a next bill date.
+
+=cut
+
+sub next_bill_date {
+  my $self = shift;
+  min( map $_->get('bill'), grep $_->get('bill'), $self->unsuspended_pkgs );
+}
+
 =item num_cancelled_pkgs
 
 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
@@ -2084,12 +2291,16 @@ Available options are:
 
 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
 
+=item nobill - can be set true to skip billing if it might otherwise be done.
+
 =back
 
 Always returns a list: an empty list on success or a list of errors.
 
 =cut
 
+# nb that dates are not specified as valid options to this method
+
 sub cancel {
   my( $self, %opt ) = @_;
 
@@ -2115,6 +2326,13 @@ sub cancel {
 
   my @pkgs = $self->ncancelled_pkgs;
 
+  if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) {
+    $opt{nobill} = 1;
+    my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 );
+    warn "Error billing during cancel, custnum ". $self->custnum. ": $error"
+      if $error;
+  }
+
   warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
        scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
     if $DEBUG;
@@ -2166,12 +2384,61 @@ sub agent {
   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
 }
 
+=item cust_class
+
+Returns the customer class, as an FS::cust_class object, or the empty string
+if there is no customer class.
+
+=cut
+
+sub cust_class {
+  my $self = shift;
+  if ( $self->classnum ) {
+    qsearchs('cust_class', { 'classnum' => $self->classnum } );
+  } else {
+    return '';
+  } 
+}
+
+=item categoryname 
+
+Returns the customer category name, or the empty string if there is no customer
+category.
+
+=cut
+
+sub categoryname {
+  my $self = shift;
+  my $cust_class = $self->cust_class;
+  $cust_class
+    ? $cust_class->categoryname
+    : '';
+}
+
+=item classname 
+
+Returns the customer class name, or the empty string if there is no customer
+class.
+
+=cut
+
+sub classname {
+  my $self = shift;
+  my $cust_class = $self->cust_class;
+  $cust_class
+    ? $cust_class->classname
+    : '';
+}
+
+
 =item bill_and_collect 
 
 Cancels and suspends any packages due, generates bills, applies payments and
-cred
+credits, and applies collection events to run cards, send bills and notices,
+etc.
 
-Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
+By default, warns on errors and continues with the next operation (but see the
+"fatal" flag below).
 
 Options are passed as name-value pairs.  Currently available options are:
 
@@ -2197,56 +2464,130 @@ Used in conjunction with the I<time> option, this option specifies the date of f
 
 If set true, re-charges setup fees.
 
+=item fatal
+
+If set any errors prevent subsequent operations from continusing.  If set
+specifically to "return", returns the error (or false, if there is no error).
+Any other true value causes errors to die.
+
 =item debug
 
 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
 
 =back
 
+Options are passed to the B<bill> and B<collect> methods verbatim, so all
+options of those methods are also available.
+
 =cut
 
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
-  ###
-  # cancel packages
-  ###
+  my $error;
 
   #$options{actual_time} not $options{time} because freeside-daily -d is for
   #pre-printing invoices
-  my @cancel_pkgs = grep { $_->expire && $_->expire <= $options{actual_time} }
-                         $self->ncancelled_pkgs;
+
+  $options{'actual_time'} ||= time;
+
+  $error = $self->cancel_expired_pkgs( $options{actual_time} );
+  if ( $error ) {
+    $error = "Error expiring custnum ". $self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
+
+  $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+  if ( $error ) {
+    $error = "Error adjourning custnum ". $self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
+
+  $error = $self->bill( %options );
+  if ( $error ) {
+    $error = "Error billing custnum ". $self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
+
+  $error = $self->apply_payments_and_credits;
+  if ( $error ) {
+    $error = "Error applying custnum ". $self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    elsif ( $options{fatal}                                ) { die    $error; }
+    else                                                     { warn   $error; }
+  }
+
+  unless ( $conf->exists('cancelled_cust-noevents')
+           && ! $self->num_ncancelled_pkgs
+  ) {
+    $error = $self->collect( %options );
+    if ( $error ) {
+      $error = "Error collecting custnum ". $self->custnum. ": $error";
+      if    ($options{fatal} && $options{fatal} eq 'return') { return $error; }
+      elsif ($options{fatal}                               ) { die    $error; }
+      else                                                   { warn   $error; }
+    }
+  }
+
+  '';
+
+}
+
+sub cancel_expired_pkgs {
+  my ( $self, $time, %options ) = @_;
+
+  my @cancel_pkgs = $self->ncancelled_pkgs( { 
+    'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
+  } );
+
+  my @errors = ();
 
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
-    my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
+    my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
                                            'reason_otaker' => $cpr->otaker
                                          )
                                        : ()
                                  );
-    warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
-  ###
-  # suspend packages
-  ###
+  scalar(@errors) ? join(' / ', @errors) : '';
 
-  #$options{actual_time} not $options{time} because freeside-daily -d is for
-  #pre-printing invoices
-  my @susp_pkgs = 
-    grep { ! $_->susp
-           && (    (    $_->part_pkg->is_prepaid
-                     && $_->bill
-                     && $_->bill < $options{actual_time}
-                   )
-                || (    $_->adjourn
-                    && $_->adjourn <= $options{actual_time}
-                  )
-              )
+}
+
+sub suspend_adjourned_pkgs {
+  my ( $self, $time, %options ) = @_;
+
+  my @susp_pkgs = $self->ncancelled_pkgs( {
+    'extra_sql' =>
+      " AND ( susp IS NULL OR susp = 0 )
+        AND (    ( bill    IS NOT NULL AND bill    != 0 AND bill    <  $time )
+              OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
+            )
+      ",
+  } );
+
+  #only because there's no SQL test for is_prepaid :/
+  @susp_pkgs = 
+    grep {     (    $_->part_pkg->is_prepaid
+                 && $_->bill
+                 && $_->bill < $time
+               )
+            || (    $_->adjourn
+                 && $_->adjourn <= $time
+               )
+           
          }
-         $self->ncancelled_pkgs;
+         @susp_pkgs;
+
+  my @errors = ();
 
   foreach my $cust_pkg ( @susp_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
@@ -2256,23 +2597,10 @@ sub bill_and_collect {
                                           )
                                         : ()
                                   );
-
-    warn "Error suspending package ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $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;
+  scalar(@errors) ? join(' / ', @errors) : '';
 
 }
 
@@ -2305,10 +2633,26 @@ An array ref of specific packages (objects) to attempt billing, instead trying a
 
  $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
 
+=item not_pkgpart
+
+A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
+
 =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 cancel
+
+This boolean value informs the us that the package is being cancelled.  This
+typically might mean not charging the normal recurring fee but only usage
+fees since the last billing. Setup charges may be charged.  Not all package
+plans support this feature (they tend to charge 0).
+
+=item invoice_terms
+
+Optional terms to be printed on this invoice.  Otherwise, customer-specific
+terms or the default terms are used.
+
 =back
 
 =cut
@@ -2322,7 +2666,12 @@ sub bill {
   my $time = $options{'time'} || time;
   my $invoice_time = $options{'invoice_time'} || $time;
 
-  #put below somehow?
+  $options{'not_pkgpart'} ||= {};
+  $options{'not_pkgpart'} = { map { $_ => 1 }
+                                  split(/\s*,\s*/, $options{'not_pkgpart'})
+                            }
+    unless ref($options{'not_pkgpart'});
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -2336,22 +2685,38 @@ sub bill {
 
   $self->select_for_update; #mutex
 
-  my @cust_bill_pkg = ();
+  my $error = $self->do_cust_event(
+    'debug'      => ( $options{'debug'} || 0 ),
+    'time'       => $invoice_time,
+    'check_freq' => $options{'check_freq'},
+    'stage'      => 'pre-bill',
+  );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  #keep auto-charge and non-auto-charge line items separate
+  my @passes = ( '', 'no_auto' );
+
+  my %cust_bill_pkg = map { $_ => [] } @passes;
 
   ###
   # find the packages which are due for billing, find out how much they are
   # & generate invoice database.
   ###
 
-  my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
-  my %taxlisthash;
+  my %total_setup   = map { my $z = 0; $_ => \$z; } @passes;
+  my %total_recur   = map { my $z = 0; $_ => \$z; } @passes;
+
+  my %taxlisthash = map { $_ => {} } @passes;
+
   my @precommit_hooks = ();
 
-  my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
-  foreach my $cust_pkg (@cust_pkgs) {
+  $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
+  foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
 
-    #NO!! next if $cust_pkg->cancel;  
-    next if $cust_pkg->getfield('cancel');  
+    next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
 
     warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
 
@@ -2368,15 +2733,18 @@ sub bill {
 
       $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
 
+      my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
+
       my $error =
         $self->_make_lines( 'part_pkg'            => $part_pkg,
                             'cust_pkg'            => $cust_pkg,
                             'precommit_hooks'     => \@precommit_hooks,
-                            'line_items'          => \@cust_bill_pkg,
-                            'setup'               => \$total_setup,
-                            'recur'               => \$total_recur,
-                            'tax_matrix'          => \%taxlisthash,
+                            'line_items'          => $cust_bill_pkg{$pass},
+                            'setup'               => $total_setup{$pass},
+                            'recur'               => $total_recur{$pass},
+                            'tax_matrix'          => $taxlisthash{$pass},
                             'time'                => $time,
+                            'real_pkgpart'        => $real_pkgpart,
                             'options'             => \%options,
                           );
       if ($error) {
@@ -2388,42 +2756,194 @@ sub bill {
 
   } #foreach my $cust_pkg
 
-  unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items
-    #but do commit any package date cycling that happened
-    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-    return '';
-  }
+  #if the customer isn't on an automatic payby, everything can go on a single
+  #invoice anyway?
+  #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
+    #merge everything into one list
+  #}
 
-  my $postal_pkg = $self->charge_postal_fee();
-  if ( $postal_pkg && !ref( $postal_pkg ) ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "can't charge postal invoice fee for customer ".
-      $self->custnum. ": $postal_pkg";
-  }
-  if ( $postal_pkg &&
-       ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
-         !$conf->exists('postal_invoice-recurring_only')
+  foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
+
+    my @cust_bill_pkg = @{ $cust_bill_pkg{$pass} };
+
+    next unless @cust_bill_pkg; #don't create an invoice w/o line items
+
+    if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
+           !$conf->exists('postal_invoice-recurring_only')
        )
-     )
-  {
-    foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
-      my $error =
-        $self->_make_lines( 'part_pkg'            => $part_pkg,
-                            'cust_pkg'            => $postal_pkg,
-                            'precommit_hooks'     => \@precommit_hooks,
-                            'line_items'          => \@cust_bill_pkg,
-                            'setup'               => \$total_setup,
-                            'recur'               => \$total_recur,
-                            'tax_matrix'          => \%taxlisthash,
-                            'time'                => $time,
-                            'options'             => \%options,
-                          );
-      if ($error) {
+    {
+
+      my $postal_pkg = $self->charge_postal_fee();
+      if ( $postal_pkg && !ref( $postal_pkg ) ) {
+
         $dbh->rollback if $oldAutoCommit;
-        return $error;
+        return "can't charge postal invoice fee for customer ".
+          $self->custnum. ": $postal_pkg";
+
+      } elsif ( $postal_pkg ) {
+
+        my $real_pkgpart = $postal_pkg->pkgpart;
+        foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
+          my %postal_options = %options;
+          delete $postal_options{cancel};
+          my $error =
+            $self->_make_lines( 'part_pkg'            => $part_pkg,
+                                'cust_pkg'            => $postal_pkg,
+                                'precommit_hooks'     => \@precommit_hooks,
+                                'line_items'          => \@cust_bill_pkg,
+                                'setup'               => $total_setup{$pass},
+                                'recur'               => $total_recur{$pass},
+                                'tax_matrix'          => $taxlisthash{$pass},
+                                'time'                => $time,
+                                'real_pkgpart'        => $real_pkgpart,
+                                'options'             => \%postal_options,
+                              );
+          if ($error) {
+            $dbh->rollback if $oldAutoCommit;
+            return $error;
+          }
+        }
+
       }
+
+    }
+
+    my $listref_or_error =
+      $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
+
+    unless ( ref( $listref_or_error ) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $listref_or_error;
+    }
+
+    foreach my $taxline ( @$listref_or_error ) {
+      ${ $total_setup{$pass} } =
+        sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
+      push @cust_bill_pkg, $taxline;
+    }
+
+    #add tax adjustments
+    warn "adding tax adjustments...\n" if $DEBUG > 2;
+    foreach my $cust_tax_adjustment (
+      qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
+                                       'billpkgnum' => '',
+                                     }
+             )
+    ) {
+
+      my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
+
+      my $itemdesc = $cust_tax_adjustment->taxname;
+      $itemdesc = '' if $itemdesc eq 'Tax';
+
+      push @cust_bill_pkg, new FS::cust_bill_pkg {
+        'pkgnum'      => 0,
+        'setup'       => $tax,
+        'recur'       => 0,
+        'sdate'       => '',
+        'edate'       => '',
+        'itemdesc'    => $itemdesc,
+        'itemcomment' => $cust_tax_adjustment->comment,
+        'cust_tax_adjustment' => $cust_tax_adjustment,
+        #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+      };
+
+    }
+
+    my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
+
+    my @cust_bill = $self->cust_bill;
+    my $balance = $self->balance;
+    my $previous_balance = scalar(@cust_bill)
+                             ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
+                             : 0;
+
+    $previous_balance += $cust_bill[$#cust_bill]->charged
+      if scalar(@cust_bill);
+    #my $balance_adjustments =
+    #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+
+    #create the new invoice
+    my $cust_bill = new FS::cust_bill ( {
+      'custnum'             => $self->custnum,
+      '_date'               => ( $invoice_time ),
+      'charged'             => $charged,
+      'billing_balance'     => $balance,
+      'previous_balance'    => $previous_balance,
+      'invoice_terms'       => $options{'invoice_terms'},
+    } );
+    $error = $cust_bill->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't create invoice for customer #". $self->custnum. ": $error";
+    }
+
+    foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
+      $cust_bill_pkg->invnum($cust_bill->invnum); 
+      my $error = $cust_bill_pkg->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "can't create invoice line item: $error";
+      }
+    }
+
+  } #foreach my $pass ( keys %cust_bill_pkg )
+
+  foreach my $hook ( @precommit_hooks ) { 
+    eval {
+      &{$hook}; #($self) ?
+    };
+    if ( $@ ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$@ running precommit hook $hook\n";
     }
   }
+  
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
+}
+
+=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
+
+This is a weird one.  Perhaps it should not even be exposed.
+
+Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
+Usually used internally by bill method B<bill>.
+
+If there is an error, returns the error, otherwise returns reference to a
+list of line items suitable for insertion.
+
+=over 4
+
+=item LINEITEMREF
+
+An array ref of the line items being billed.
+
+=item TAXHASHREF
+
+A strange beast.  The keys to this hash are internal identifiers consisting
+of the name of the tax object type, a space, and its unique identifier ( e.g.
+ 'cust_main_county 23' ).  The values of the hash are listrefs.  The first
+item in the list is the tax object.  The remaining items are either line
+items or floating point values (currency amounts).
+
+The taxes are calculated on this entity.  Calculated exemption records are
+transferred to the LINEITEMREF items on the assumption that they are related.
+
+Read the source.
+
+=item INVOICE_TIME
+
+This specifies the date appearing on the associated invoice.  Some
+jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
+
+=back
+
+=cut
+sub calculate_taxes {
+  my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
+
+  my @tax_line_items = ();
 
   warn "having a look at the taxes we found...\n" if $DEBUG > 2;
 
@@ -2439,19 +2959,22 @@ sub bill {
   # values are listrefs of cust_bill_pkg_tax_location hashrefs
   my %tax_location = ();
 
-  foreach my $tax ( keys %taxlisthash ) {
-    my $tax_object = shift @{ $taxlisthash{$tax} };
+  # keys are taxlisthash keys (internal identifiers)
+  # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
+  my %tax_rate_location = ();
+
+  foreach my $tax ( keys %$taxlisthash ) {
+    my $tax_object = shift @{ $taxlisthash->{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
+    warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
     my $hashref_or_error =
-      $tax_object->taxline( $taxlisthash{$tax},
+      $tax_object->taxline( $taxlisthash->{$tax},
                             'custnum'      => $self->custnum,
                             'invoice_time' => $invoice_time
                           );
-    unless ( ref($hashref_or_error) ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $hashref_or_error;
-    }
-    unshift @{ $taxlisthash{$tax} }, $tax_object;
+    return $hashref_or_error unless ref($hashref_or_error);
+
+    unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
     my $name   = $hashref_or_error->{'name'};
     my $amount = $hashref_or_error->{'amount'};
@@ -2474,72 +2997,40 @@ sub bill {
         };
     }
 
+    $tax_rate_location{ $tax } ||= [];
+    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+      my $taxratelocationnum =
+        $tax_object->tax_rate_location->taxratelocationnum;
+      push @{ $tax_rate_location{ $tax }  },
+        {
+          'taxnum'             => $tax_object->taxnum, 
+          'taxtype'            => ref($tax_object),
+          'amount'             => sprintf('%.2f', $amount ),
+          'locationtaxid'      => $tax_object->location,
+          'taxratelocationnum' => $taxratelocationnum,
+        };
+    }
+
   }
 
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
-  my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
-  foreach my $tax ( keys %taxlisthash ) {
-    foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
-      next unless ref($_) eq 'FS::cust_bill_pkg'; # shouldn't happen
+  my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
+  foreach my $tax ( keys %$taxlisthash ) {
+    foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
+      next unless ref($_) eq 'FS::cust_bill_pkg';
 
       push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
         splice( @{ $_->_cust_tax_exempt_pkg } );
     }
   }
 
-  #some taxes are taxed
-  my %totlisthash;
-  
-  warn "finding taxed taxes...\n" if $DEBUG > 2;
-  foreach my $tax ( keys %taxlisthash ) {
-    my $tax_object = shift @{ $taxlisthash{$tax} };
-    warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
-      if $DEBUG > 2;
-    next unless $tax_object->can('tax_on_tax');
-
-    foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
-      my $totname = ref( $tot ). ' '. $tot->taxnum;
-
-      warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
-        if $DEBUG > 2;
-      next unless exists( $taxlisthash{ $totname } ); # only increase
-                                                      # existing taxes
-      warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
-      if ( exists( $totlisthash{ $totname } ) ) {
-        push @{ $totlisthash{ $totname  } }, $tax{ $tax };
-      }else{
-        $totlisthash{ $totname } = [ $tot, $tax{ $tax } ];
-      }
-    }
-  }
-
-  warn "having a look at taxed taxes...\n" if $DEBUG > 2;
-  foreach my $tax ( keys %totlisthash ) {
-    my $tax_object = shift @{ $totlisthash{$tax} };
-    warn "found previously found taxed tax ". $tax_object->taxname. "\n"
-      if $DEBUG > 2;
-    my $listref_or_error =
-      $tax_object->taxline( $totlisthash{$tax},
-                            'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time
-                          );
-    unless (ref($listref_or_error)) {
-      $dbh->rollback if $oldAutoCommit;
-      return $listref_or_error;
-    }
-
-    warn "adding taxed tax amount ". $listref_or_error->[1].
-         " as ". $tax_object->taxname. "\n"
-      if $DEBUG;
-    $tax{ $tax } += $listref_or_error->[1];
-  }
-  
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
     my $tax = 0;
     my %seen = ();
     my @cust_bill_pkg_tax_location = ();
+    my @cust_bill_pkg_tax_rate_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
       next if $seen{$taxitem}++;
@@ -2548,63 +3039,48 @@ sub bill {
       push @cust_bill_pkg_tax_location,
         map { new FS::cust_bill_pkg_tax_location $_ }
             @{ $tax_location{ $taxitem } };
+      push @cust_bill_pkg_tax_rate_location,
+        map { new FS::cust_bill_pkg_tax_rate_location $_ }
+            @{ $tax_rate_location{ $taxitem } };
     }
     next unless $tax;
 
     $tax = sprintf('%.2f', $tax );
-    $total_setup = sprintf('%.2f', $total_setup+$tax );
   
-    push @cust_bill_pkg, new FS::cust_bill_pkg {
+    my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
+                                                   'disabled'     => '',
+                                                 },
+                               );
+
+    my @display = ();
+    if ( $pkg_category and
+         $conf->config('invoice_latexsummary') ||
+         $conf->config('invoice_htmlsummary')
+       )
+    {
+
+      my %hash = (  'section' => $pkg_category->categoryname );
+      push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+
+    }
+
+    push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
       'setup'    => $tax,
       'recur'    => 0,
       'sdate'    => '',
       'edate'    => '',
       'itemdesc' => $taxname,
+      'display'  => \@display,
       'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
     };
 
   }
 
-  my $charged = sprintf('%.2f', $total_setup + $total_recur );
-
-  #create the new invoice
-  my $cust_bill = new FS::cust_bill ( {
-    'custnum' => $self->custnum,
-    '_date'   => ( $invoice_time ),
-    'charged' => $charged,
-  } );
-  my $error = $cust_bill->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "can't create invoice for customer #". $self->custnum. ": $error";
-  }
-
-  foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
-    $cust_bill_pkg->invnum($cust_bill->invnum); 
-    my $error = $cust_bill_pkg->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't create invoice line item: $error";
-    }
-  }
-    
-
-  foreach my $hook ( @precommit_hooks ) { 
-    eval {
-      &{$hook}; #($self) ?
-    };
-    if ( $@ ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "$@ running precommit hook $hook\n";
-    }
-  }
-  
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
+  \@tax_line_items;
 }
 
-
 sub _make_lines {
   my ($self, %params) = @_;
 
@@ -2616,15 +3092,15 @@ sub _make_lines {
   my $total_recur = $params{recur} or die "no recur accumulator specified";
   my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
   my $time = $params{'time'} or die "no time specified";
-  my (%options) = %{$params{options}};  #hmmm  only for 'resetup'
+  my (%options) = %{$params{options}};
 
   my $dbh = dbh;
-  my $real_pkgpart = $cust_pkg->pkgpart;
+  my $real_pkgpart = $params{real_pkgpart};
   my %hash = $cust_pkg->hash;
   my $old_cust_pkg = new FS::cust_pkg \%hash;
 
   my @details = ();
-
+  my @discounts = ();
   my $lineitems = 0;
 
   $cust_pkg->pkgpart($part_pkg->pkgpart);
@@ -2635,14 +3111,19 @@ sub _make_lines {
 
   my $setup = 0;
   my $unitsetup = 0;
-  if ( ! $cust_pkg->setup &&
-       (
-         ( $conf->exists('disable_setup_suspended_pkgs') &&
-          ! $cust_pkg->getfield('susp')
-        ) || ! $conf->exists('disable_setup_suspended_pkgs')
-       )
-    || $options{'resetup'}
-  ) {
+  if ( $options{'resetup'}
+       || ( ! $cust_pkg->setup
+            && ( ! $cust_pkg->start_date
+                 || $cust_pkg->start_date <= $time
+               )
+            && ( ! $conf->exists('disable_setup_suspended_pkgs')
+                 || ( $conf->exists('disable_setup_suspended_pkgs') &&
+                      ! $cust_pkg->getfield('susp')
+                    )
+               )
+          )
+    )
+  {
     
     warn "    bill setup\n" if $DEBUG > 1;
     $lineitems++;
@@ -2658,6 +3139,9 @@ sub _make_lines {
           #do need it, but it won't get written to the db
           #|| $cust_pkg->pkgpart != $real_pkgpart;
 
+    $cust_pkg->setfield('start_date', '')
+      if $cust_pkg->start_date;
+
   }
 
   ###
@@ -2668,13 +3152,15 @@ sub _make_lines {
   my $recur = 0;
   my $unitrecur = 0;
   my $sdate;
-  if ( ! $cust_pkg->getfield('susp') and
-           ( $part_pkg->getfield('freq') ne '0' &&
-             ( $cust_pkg->getfield('bill') || 0 ) <= $time
+  if (     ! $cust_pkg->get('susp')
+       and ! $cust_pkg->get('start_date')
+       and ( $part_pkg->getfield('freq') ne '0'
+             && ( $cust_pkg->getfield('bill') || 0 ) <= $time
            )
         || ( $part_pkg->plan eq 'voip_cdr'
               && $part_pkg->option('bill_every_call')
            )
+        || ( $options{cancel} )
   ) {
 
     # XXX should this be a package event?  probably.  events are called
@@ -2688,18 +3174,23 @@ sub _make_lines {
     $lineitems++;
 
     # XXX shared with $recur_prog
-    $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
+    $sdate = ( $options{cancel} ? $cust_pkg->last_bill : $cust_pkg->bill )
+             || $cust_pkg->setup
+             || $time;
 
     #over two params!  lets at least switch to a hashref for the rest...
     my $increment_next_bill = ( $part_pkg->freq ne '0'
                                 && ( $cust_pkg->getfield('bill') || 0 ) <= $time
+                                && !$options{cancel}
                               );
     my %param = ( 'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
+                  'discounts'           => \@discounts,
                 );
 
-    $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) };
-    return "$@ running calc_recur for $cust_pkg\n"
+    my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
+    $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
+    return "$@ running $method for $cust_pkg\n"
       if ( $@ );
 
     if ( $increment_next_bill ) {
@@ -2774,14 +3265,18 @@ sub _make_lines {
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
+        'discounts' => \@discounts,
+        'hidden'    => $part_pkg->hidden,
       };
 
       if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
         $cust_bill_pkg->sdate( $hash{last_bill} );
         $cust_bill_pkg->edate( $sdate - 86399   ); #60s*60m*24h-1
+        $cust_bill_pkg->edate( $time ) if $options{cancel};
       } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
         $cust_bill_pkg->sdate( $sdate );
         $cust_bill_pkg->edate( $cust_pkg->bill );
+        #$cust_bill_pkg->edate( $time ) if $options{cancel};
       }
 
       $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
@@ -2795,7 +3290,7 @@ sub _make_lines {
       ###
 
       my $error = 
-        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg);
+        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
       return $error if $error;
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
@@ -2814,6 +3309,9 @@ sub _handle_taxes {
   my $taxlisthash = shift;
   my $cust_bill_pkg = shift;
   my $cust_pkg = shift;
+  my $invoice_time = shift;
+  my $real_pkgpart = shift;
+  my $options = shift;
 
   my %cust_bill_pkg = ();
   my %taxes = ();
@@ -2821,8 +3319,8 @@ sub _handle_taxes {
   my @classes;
   #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
-  push @classes, 'setup' if $cust_bill_pkg->setup;
-  push @classes, 'recur' if $cust_bill_pkg->recur;
+  push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
+  push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
 
   if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
 
@@ -2851,7 +3349,7 @@ sub _handle_taxes {
 
     } else {
 
-      my @loc_keys = qw( state county country );
+      my @loc_keys = qw( city county state country );
       my %taxhash;
       if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
         my $cust_location = $cust_pkg->cust_location;
@@ -2866,15 +3364,28 @@ sub _handle_taxes {
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      my @taxes = qsearch( 'cust_main_county', \%taxhash );
-
+      my @taxes = ();
       my %taxhash_elim = %taxhash;
+      my @elim = qw( city county state );
+      do { 
 
-      my @elim = qw( taxclass county state );
-      while ( !scalar(@taxes) && scalar(@elim) ) {
-        $taxhash_elim{ shift(@elim) } = '';
+        #first try a match with taxclass
         @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
-      }
+
+        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+          #then try a match without taxclass
+          my %no_taxclass = %taxhash_elim;
+          $no_taxclass{ 'taxclass' } = '';
+          @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+        }
+
+        $taxhash_elim{ shift(@elim) } = '';
+
+      } while ( !scalar(@taxes) && scalar(@elim) );
+
+      @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
+                    @taxes
+        if $self->cust_main_exemption; #just to be safe
 
       if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
         foreach (@taxes) {
@@ -2888,32 +3399,53 @@ sub _handle_taxes {
       $taxes{'recur'} = [ @taxes ];
       $taxes{$_} = [ @taxes ] foreach (@classes);
 
-      # maybe eliminate this entirely, along with all the 0% records
-      unless ( @taxes ) {
-        return
-          "fatal: can't find tax rate for state/county/country/taxclass ".
-          join('/', map $taxhash{$_}, qw(state county country taxclass) );
-      }
+      # maybe eliminate this entirely, along with all the 0% records
+      unless ( @taxes ) {
+        return
+          "fatal: can't find tax rate for state/county/country/taxclass ".
+          join('/', map $taxhash{$_}, qw(state county country taxclass) );
+      }
 
     } #if $conf->exists('enable_taxproducts') ...
 
   }
  
   my @display = ();
-  if ( $conf->exists('separate_usage') ) {
+  my $separate = $conf->exists('separate_usage');
+  my $usage_mandate = $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
+  if ( $separate || $cust_bill_pkg->hidden || $usage_mandate ) {
+
+    my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
+    my %hash = $cust_bill_pkg->hidden  # maybe for all bill linked?
+               ? (  'section' => $temp_pkg->part_pkg->categoryname )
+               : ();
+
     my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
     my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
-    push @display, new FS::cust_bill_pkg_display { type    => 'S' };
-    push @display, new FS::cust_bill_pkg_display { type    => 'R' };
-    push @display, new FS::cust_bill_pkg_display { type    => 'U',
-                                                   section => $section
-                                                 };
-    if ($section && $summary) {
-      $display[2]->post_total('Y');
+    if ( $separate ) {
+      push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+      push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
+    } else {
+      push @display, new FS::cust_bill_pkg_display
+                       { type => '',
+                         %hash,
+                         ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
+                       };
+    }
+
+    if ($separate && $section && $summary) {
       push @display, new FS::cust_bill_pkg_display { type    => 'U',
                                                      summary => 'Y',
-                                                   }
+                                                     %hash,
+                                                   };
     }
+    if ($usage_mandate || $section && $summary) {
+      $hash{post_total} = 'Y';
+    }
+
+    $hash{section} = $section if ($separate || $usage_mandate);
+    push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
+
   }
   $cust_bill_pkg->set('display', \@display);
 
@@ -2922,19 +3454,51 @@ sub _handle_taxes {
     my @taxes = @{ $taxes{$key} || [] };
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
+    my %localtaxlisthash = ();
     foreach my $tax ( @taxes ) {
 
-      my $taxname = ref( $tax ). ' taxnum'. $tax->taxnum;
+      my $taxname = ref( $tax ). ' '. $tax->taxnum;
 #      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
 #                  ' locationnum'. $cust_pkg->locationnum
 #        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
 
-      if ( exists( $taxlisthash->{ $taxname } ) ) {
-        push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
-      }else{
-        $taxlisthash->{ $taxname } = [ $tax, $tax_cust_bill_pkg ];
+      $taxlisthash->{ $taxname } ||= [ $tax ];
+      push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
+
+      $localtaxlisthash{ $taxname } ||= [ $tax ];
+      push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg;
+
+    }
+
+    warn "finding taxed taxes...\n" if $DEBUG > 2;
+    foreach my $tax ( keys %localtaxlisthash ) {
+      my $tax_object = shift @{ $localtaxlisthash{$tax} };
+      warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
+        if $DEBUG > 2;
+      next unless $tax_object->can('tax_on_tax');
+
+      foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+        my $totname = ref( $tot ). ' '. $tot->taxnum;
+
+        warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
+          if $DEBUG > 2;
+        next unless exists( $localtaxlisthash{ $totname } ); # only increase
+                                                             # existing taxes
+        warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
+        my $hashref_or_error = 
+          $tax_object->taxline( $localtaxlisthash{$tax},
+                                'custnum'      => $self->custnum,
+                                'invoice_time' => $invoice_time,
+                              );
+        return $hashref_or_error
+          unless ref($hashref_or_error);
+        
+        $taxlisthash->{ $totname } ||= [ $tot ];
+        push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
+
       }
     }
+
   }
 
   '';
@@ -2953,6 +3517,7 @@ sub _gather_taxes {
 
   unless (@taxclassnums) {
     @taxclassnums = map { $_->taxclassnum }
+                    grep { $_->taxable eq 'Y' }
                     $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
   }
   warn "Found taxclassnum values of ". join(',', @taxclassnums)
@@ -2976,7 +3541,7 @@ sub _gather_taxes {
 
 }
 
-=item collect OPTIONS
+=item collect [ HASHREF | OPTION => VALUE ... ]
 
 (Attempt to) collect money for this customer's outstanding invoices (see
 L<FS::cust_bill>).  Usually used after the bill method.
@@ -3001,25 +3566,24 @@ Use this time when deciding when to print invoices and late notices on those inv
 
 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)
 
-=item payby
+=item quiet
 
-allows for one time override of normal customer billing method
+set true to surpress email card/ACH decline notices.
 
 =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
 
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
 =cut
 
 sub collect {
@@ -3057,12 +3621,107 @@ sub collect {
     }
   }
 
+  my $error = $self->do_cust_event(
+    'debug'      => ( $options{'debug'} || 0 ),
+    'time'       => $invoice_time,
+    'check_freq' => $options{'check_freq'},
+    'stage'      => 'collect',
+  );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=item do_cust_event [ HASHREF | OPTION => VALUE ... ]
+
+Runs billing events; see L<FS::part_event> and the billing events web
+interface.
+
+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
+
+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 check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item stage
+
+"collect" (the default) or "pre-bill"
+
+=item quiet
+set true to surpress email card/ACH decline notices.
+
+=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)
+
+=cut
+
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
+# =item retry
+#
+# Retry card/echeck/LEC transactions even when not scheduled by invoice events.
+
+sub do_cust_event {
+  my( $self, %options ) = @_;
+  my $time = $options{'time'} || time;
+
+  #put below somehow?
+  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
+
+  if ( $DEBUG ) {
+    my $balance = $self->balance;
+    warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
+  }
+
+#  if ( exists($options{'retry_card'}) ) {
+#    carp 'retry_card option passed to collect is deprecated; use retry';
+#    $options{'retry'} ||= $options{'retry_card'};
+#  }
+#  if ( exists($options{'retry'}) && $options{'retry'} ) {
+#    my $error = $self->retry_realtime;
+#    if ( $error ) {
+#      $dbh->rollback if $oldAutoCommit;
+#      return $error;
+#    }
+#  }
+
   # false laziness w/pay_batch::import_results
 
   my $due_cust_event = $self->due_cust_event(
     'debug'      => ( $options{'debug'} || 0 ),
-    'time'       => $invoice_time,
+    'time'       => $time,
     'check_freq' => $options{'check_freq'},
+    'stage'      => ( $options{'stage'} || 'collect' ),
   );
   unless( ref($due_cust_event) ) {
     $dbh->rollback if $oldAutoCommit;
@@ -3074,7 +3733,7 @@ sub collect {
     #XXX lock event
     
     #re-eval event conditions (a previous event could have changed things)
-    unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
+    unless ( $cust_event->test_conditions( 'time' => $time ) ) {
       #don't leave stray "new/locked" records around
       my $error = $cust_event->delete;
       if ( $error ) {
@@ -3127,6 +3786,10 @@ options are:
 
 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 stage
+
+"collect" (the default) or "pre-bill"
+
 =item time
 
 "Current time" for the events.
@@ -3182,7 +3845,7 @@ sub due_cust_event {
     unless $opt{testonly};
 
   ###
-  # 1: find possible events (initial search)
+  # find possible events (initial search)
   ###
   
   my @cust_event = ();
@@ -3273,8 +3936,20 @@ sub due_cust_event {
        " total possible cust events found in initial search\n"
     if $DEBUG; # > 1;
 
+
+  ##
+  # test stage
+  ##
+
+  $opt{stage} ||= 'collect';
+  @cust_event =
+    grep { my $stage = $_->part_event->event_stage;
+           $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
+         }
+         @cust_event;
+
   ##
-  # 2: test conditions
+  # test conditions
   ##
   
   my %unsat = ();
@@ -3288,10 +3963,10 @@ sub due_cust_event {
 
   warn "    invalid conditions not eliminated with condition_sql:\n".
        join('', map "      $_: ".$unsat{$_}."\n", keys %unsat )
-    if $DEBUG; # > 1;
+    if keys %unsat && $DEBUG; # > 1;
 
   ##
-  # 3: insert
+  # insert
   ##
 
   unless( $opt{testonly} ) {
@@ -3309,7 +3984,7 @@ sub due_cust_event {
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ##
-  # 4: return
+  # return
   ##
 
   warn "  returning events: ". Dumper(@cust_event). "\n"
@@ -3428,11 +4103,14 @@ I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
 if set, will override the value from the customer record.
 
 I<description> is a free-text field passed to the gateway.  It defaults to
-"Internet services".
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
 specified invoice.  If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method.
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
 
 I<quiet> can be set true to surpress email decline notices.
 
@@ -3451,13 +4129,34 @@ sub realtime_bop {
   return $self->_new_realtime_bop(@_)
     if $self->_new_bop_required();
 
-  my( $method, $amount, %options ) = @_;
+  my($method, $amount);
+  my %options = ();
+  if (ref($_[0]) eq 'HASH') {
+    %options = %{$_[0]};
+    $method = $options{method};
+    $amount = $options{amount};
+  } else {
+    ( $method, $amount ) = ( shift, shift );
+    %options = @_;
+  }
   if ( $DEBUG ) {
     warn "$me realtime_bop: $method $amount\n";
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
-  $options{'description'} ||= 'Internet services';
+  return "Amount must be greater than 0" unless $amount > 0;
+
+  unless ( $options{'description'} ) {
+    if ( $conf->exists('business-onlinepayment-description') ) {
+      my $dtempl = $conf->config('business-onlinepayment-description');
+
+      my $agent = $self->agent->agent;
+      #$pkgs... not here
+      $options{'description'} = eval qq("$dtempl");
+    } else {
+      $options{'description'} = 'Internet services';
+    }
+  }
 
   return $self->fake_bop($method, $amount, %options) if $options{'fake'};
 
@@ -3485,24 +4184,35 @@ sub realtime_bop {
   return "Banned credit card" if $ban;
 
   ###
-  # select a gateway
+  # set taxclass and trans_is_recur based on invnum if there is one
   ###
 
   my $taxclass = '';
+  my $trans_is_recur = 0;
   if ( $options{'invnum'} ) {
+
     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
     die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-    my @taxclasses =
-      map  { $_->part_pkg->taxclass }
+
+    my @part_pkg =
+      map  { $_->part_pkg }
       grep { $_ }
       map  { $_->cust_pkg }
       $cust_bill->cust_bill_pkg;
-    unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
-                                                           #different taxclasses
-      $taxclass = $taxclasses[0];
-    }
+
+    my @taxclasses = map $_->taxclass, @part_pkg;
+    $taxclass = $taxclasses[0]
+      unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
+                                                        #different taxclasses
+    $trans_is_recur = 1
+      if grep { $_->freq ne '0' } @part_pkg;
+
   }
 
+  ###
+  # select a gateway
+  ###
+
   #look for an agent gateway override first
   my $cardtype;
   if ( $method eq 'CC' ) {
@@ -3630,16 +4340,15 @@ sub realtime_bop {
                            : $self->payissue;
     $content{issue_number} = $payissue if $payissue;
 
-    $content{recurring_billing} = 'YES'
-      if qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'payinfo' => $payinfo,
-                             } )
-      || qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $payinfo),
-                             } );
-
+    if ( $self->_bop_recurring_billing( 'payinfo'        => $payinfo,
+                                        'trans_is_recur' => $trans_is_recur,
+                                      )
+       )
+    {
+      $content{recurring_billing} = 'YES';
+      $content{acct_code} = 'rebill'
+        if $conf->exists('credit_card-recurring_billing_acct_code');
+    }
 
   } elsif ( $method eq 'ECHECK' ) {
     ( $content{account_number}, $content{routing_code} ) =
@@ -3698,15 +4407,17 @@ sub realtime_bop {
   #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 : '' ),
+    'custnum'           => $self->custnum,
+    #'invnum'            => $options{'invnum'},
+    'paid'              => $amount,
+    '_date'             => '',
+    'payby'             => $method2payby{$method},
+    'payinfo'           => $payinfo,
+    'paydate'           => $paydate,
+    'recurring_billing' => $content{recurring_billing},
+    'pkgnum'            => $options{'pkgnum'},
+    'status'            => 'new',
+    'gatewaynum'        => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -3860,6 +4571,7 @@ sub realtime_bop {
        'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
        'paydate'  => $paydate,
+       'pkgnum'   => $options{'pkgnum'},
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -3912,6 +4624,16 @@ sub realtime_bop {
     } else {
 
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+      if ( $options{'apply'} ) {
+        my $apply_error = $self->apply_payments_and_credits;
+        if ( $apply_error ) {
+          warn "WARNING: error applying payment: $apply_error\n";
+          #but we still should return no error cause the payment otherwise went
+          #through...
+        }
+      }
+
       return ''; #no error
 
     }
@@ -3962,7 +4684,13 @@ sub realtime_bop {
       $template->compile()
         or return "($perror) can't compile template: $Text::Template::ERROR";
 
-      my $templ_hash = { error => $transaction->error_message };
+      my $templ_hash = {
+        'company_name'    =>
+          scalar( $conf->config('company_name', $self->agentnum ) ),
+        'company_address' =>
+          join("\n", $conf->config('company_address', $self->agentnum ) ),
+        'error'           => $transaction->error_message,
+      };
 
       my $error = send_email(
         'from'    => $conf->config('invoice_from', $self->agentnum ),
@@ -3992,6 +4720,34 @@ sub realtime_bop {
 
 }
 
+sub _bop_recurring_billing {
+  my( $self, %opt ) = @_;
+
+  my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
+
+  if ( defined($method) && $method eq 'transaction_is_recur' ) {
+
+    return 1 if $opt{'trans_is_recur'};
+
+  } else {
+
+    my %hash = ( 'custnum' => $self->custnum,
+                 'payby'   => 'CARD',
+               );
+
+    return 1 
+      if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
+      || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
+                                                               $opt{'payinfo'} )
+                             } );
+
+  }
+
+  return 0;
+
+}
+
+
 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
 
 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
@@ -4160,6 +4916,19 @@ sub realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    if ( $void->can('info') ) {
+      if ( $cust_pay->payby eq 'CARD'
+           && $void->info('CC_void_requires_card') )
+      {
+        $content{'card_number'} = $cust_pay->payinfo
+      } elsif ( $cust_pay->payby eq 'CHEK'
+                && $void->info('ECHECK_void_requires_account') )
+      {
+        ( $content{'account_number'}, $content{'routing_code'} ) =
+          split('@', $cust_pay->payinfo);
+        $content{'name'} = $self->get('first'). ' '. $self->get('last');
+      }
+    }
     $void->content( 'action' => 'void', %content );
     $void->submit();
     if ( $void->is_success ) {
@@ -4323,17 +5092,18 @@ sub _new_bop_required {
   my $botpp = 'Business::OnlineThirdPartyPayment';
 
   return 1
-    if ( $conf->config('business-onlinepayment-namespace') eq $botpp ||
-         scalar( grep { $_->gateway_namespace eq $botpp } 
-                 qsearch( 'payment_gateway', { 'disabled' => '' } )
-               )
+    if (   (     $conf->exists('business-onlinepayment-namespace')
+             &&  $conf->config('business-onlinepayment-namespace') eq $botpp
+           )
+         or scalar( grep { $_->gateway_namespace eq $botpp } 
+                    qsearch( 'payment_gateway', { 'disabled' => '' } )
+                  )
        )
   ;
 
   '';
 }
   
-
 =item realtime_collect [ OPTION => VALUE ... ]
 
 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
@@ -4345,7 +5115,7 @@ On failure returns an error message.
 
 Returns false or a hashref upon success.  The hashref contains keys popup_url reference, and collectitems.  The first is a URL to which a browser should be redirected for completion of collection.  The second is a reference id for the transaction suitable for the end user.  The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
 
-Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
 
 I<method> is one of: I<CC>, I<ECHECK> and I<LEC>.  If none is specified
 then it is deduced from the customer record.
@@ -4357,11 +5127,14 @@ I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
 if set, will override the value from the customer record.
 
 I<description> is a free-text field passed to the gateway.  It defaults to
-"Internet services".
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
 specified invoice.  If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method.
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
 
 I<quiet> can be set true to surpress email decline notices.
 
@@ -4409,7 +5182,8 @@ I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
 if set, will override the value from the customer record.
 
 I<description> is a free-text field passed to the gateway.  It defaults to
-"Internet services".
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
 specified invoice.  If you don't specify an I<invnum> you might want to
@@ -4460,7 +5234,18 @@ sub _bop_options {
 sub _bop_defaults {
   my ($self, $options) = @_;
 
-  $options->{description} ||= 'Internet services';
+  unless ( $options->{'description'} ) {
+    if ( $conf->exists('business-onlinepayment-description') ) {
+      my $dtempl = $conf->config('business-onlinepayment-description');
+
+      my $agent = $self->agent->agent;
+      #$pkgs... not here
+      $options->{'description'} = eval qq("$dtempl");
+    } else {
+      $options->{'description'} = 'Internet services';
+    }
+  }
+
   $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
   $options->{invnum} ||= '';
   $options->{payname} = $self->payname unless exists( $options->{payname} );
@@ -4544,6 +5329,27 @@ sub _new_realtime_bop {
   $self->_bop_defaults(\%options);
 
   ###
+  # set trans_is_recur based on invnum if there is one
+  ###
+
+  my $trans_is_recur = 0;
+  if ( $options{'invnum'} ) {
+
+    my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
+    die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
+
+    my @part_pkg =
+      map  { $_->part_pkg }
+      grep { $_ }
+      map  { $_->cust_pkg }
+      $cust_bill->cust_bill_pkg;
+
+    $trans_is_recur = 1
+      if grep { $_->freq ne '0' } @part_pkg;
+
+  }
+
+  ###
   # select a gateway
   ###
 
@@ -4619,16 +5425,15 @@ sub _new_realtime_bop {
                            : $self->payissue;
     $content{issue_number} = $payissue if $payissue;
 
-    $content{recurring_billing} = 'YES'
-      if qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'payinfo' => $options{payinfo},
-                             } )
-      || qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $options{payinfo}),
-                             } );
-
+    if ( $self->_bop_recurring_billing( 'payinfo'        => $options{'payinfo'},
+                                        'trans_is_recur' => $trans_is_recur,
+                                      )
+       )
+    {
+      $content{recurring_billing} = 'YES';
+      $content{acct_code} = 'rebill'
+        if $conf->exists('credit_card-recurring_billing_acct_code');
+    }
 
   } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
     ( $content{account_number}, $content{routing_code} ) =
@@ -4690,17 +5495,19 @@ sub _new_realtime_bop {
   #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'       => $options{amount},
-    '_date'      => '',
-    'payby'      => $bop_method2payby{$options{method}},
-    'payinfo'    => $options{payinfo},
-    'paydate'    => $paydate,
-    'status'     => 'new',
-    'gatewaynum' => $payment_gateway->gatewaynum || '',
-    'session_id' => $options{session_id} || '',
-    'jobnum'     => $options{depend_jobnum} || '',
+    'custnum'           => $self->custnum,
+    #'invnum'            => $options{'invnum'},
+    'paid'              => $options{amount},
+    '_date'             => '',
+    'payby'             => $bop_method2payby{$options{method}},
+    'payinfo'           => $options{payinfo},
+    'paydate'           => $paydate,
+    'recurring_billing' => $content{recurring_billing},
+    'pkgnum'            => $options{'pkgnum'},
+    'status'            => 'new',
+    'gatewaynum'        => $payment_gateway->gatewaynum || '',
+    'session_id'        => $options{session_id} || '',
+    'jobnum'            => $options{depend_jobnum} || '',
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -4943,6 +5750,7 @@ sub _realtime_bop_result {
        #'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
        'paydate'  => $cust_pay_pending->paydate,
+       'pkgnum'   => $cust_pay_pending->pkgnum,
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -5021,6 +5829,16 @@ sub _realtime_bop_result {
     } else {
 
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+      if ( $options{'apply'} ) {
+        my $apply_error = $self->apply_payments_and_credits;
+        if ( $apply_error ) {
+          warn "WARNING: error applying payment: $apply_error\n";
+          #but we still should return no error cause the payment otherwise went
+          #through...
+        }
+      }
+
       return ''; #no error
 
     }
@@ -5091,7 +5909,13 @@ sub _realtime_bop_result {
       $template->compile()
         or return "($perror) can't compile template: $Text::Template::ERROR";
 
-      my $templ_hash = { error => $transaction->error_message };
+      my $templ_hash = {
+        'company_name'    =>
+          scalar( $conf->config('company_name', $self->agentnum ) ),
+        'company_address' =>
+          join("\n", $conf->config('company_address', $self->agentnum ) ),
+        'error'           => $transaction->error_message,
+      };
 
       my $error = send_email(
         'from'    => $conf->config('invoice_from', $self->agentnum ),
@@ -5445,6 +6269,19 @@ sub _new_realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    if ( $void->can('info') ) {
+      if ( $cust_pay->payby eq 'CARD'
+           && $void->info('CC_void_requires_card') )
+      {
+        $content{'card_number'} = $cust_pay->payinfo;
+      } elsif ( $cust_pay->payby eq 'CHEK'
+                && $void->info('ECHECK_void_requires_account') )
+      {
+        ( $content{'account_number'}, $content{'routing_code'} ) =
+          split('@', $cust_pay->payinfo);
+        $content{'name'} = $self->get('first'). ' '. $self->get('last');
+      }
+    }
     $void->content( 'action' => 'void', %content );
     $void->submit();
     if ( $void->is_success ) {
@@ -5724,19 +6561,23 @@ sub batch_card {
   '';
 }
 
-=item apply_payments_and_credits
+=item apply_payments_and_credits [ OPTION => VALUE ... ]
 
 Applies unapplied payments and credits.
 
 In most cases, this new method should be used in place of sequential
 apply_payments and apply_credits methods.
 
+A hash of optional arguments may be passed.  Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
 If there is an error, returns the error, otherwise returns false.
 
 =cut
 
 sub apply_payments_and_credits {
-  my $self = shift;
+  my( $self, %options ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -5752,7 +6593,7 @@ sub apply_payments_and_credits {
   $self->select_for_update; #mutex
 
   foreach my $cust_bill ( $self->open_cust_bill ) {
-    my $error = $cust_bill->apply_payments_and_credits;
+    my $error = $cust_bill->apply_payments_and_credits(%options);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "Error applying: $error";
@@ -5805,32 +6646,52 @@ sub apply_credits {
   @invoices = sort { $b->_date <=> $a->_date } @invoices
     if defined($opt{'order'}) && $opt{'order'} eq 'newest';
 
+  if ( $conf->exists('pkg-balances') ) {
+    # limit @credits to those w/ a pkgnum grepped from $self
+    my %pkgnums = ();
+    foreach my $i (@invoices) {
+      foreach my $li ( $i->cust_bill_pkg ) {
+        $pkgnums{$li->pkgnum} = 1;
+      }
+    }
+    @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+  }
+
   my $credit;
+
   foreach my $cust_bill ( @invoices ) {
-    my $amount;
 
     if ( !defined($credit) || $credit->credited == 0) {
       $credit = pop @credits or last;
     }
 
-    if ($cust_bill->owed >= $credit->credited) {
-      $amount=$credit->credited;
-    }else{
-      $amount=$cust_bill->owed;
+    my $owed;
+    if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
+      $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
+    } else {
+      $owed = $cust_bill->owed;
+    }
+    unless ( $owed > 0 ) {
+      push @credits, $credit;
+      next;
     }
+
+    my $amount = min( $credit->credited, $owed );
     
     my $cust_credit_bill = new FS::cust_credit_bill ( {
       'crednum' => $credit->crednum,
       'invnum'  => $cust_bill->invnum,
       'amount'  => $amount,
     } );
+    $cust_credit_bill->pkgnum( $credit->pkgnum )
+      if $conf->exists('pkg-balances') && $credit->pkgnum;
     my $error = $cust_credit_bill->insert;
     if ( $error ) {
       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
       die $error;
     }
     
-    redo if ($cust_bill->owed > 0);
+    redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
 
   }
 
@@ -5841,19 +6702,23 @@ sub apply_credits {
   return $total_unapplied_credits;
 }
 
-=item apply_payments
+=item apply_payments  [ OPTION => VALUE ... ]
 
 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
 to outstanding invoice balances in chronological order.
 
  #and returns the value of any remaining unapplied payments.
 
+A hash of optional arguments may be passed.  Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
 Dies if there is an error.
 
 =cut
 
 sub apply_payments {
-  my $self = shift;
+  my( $self, %options ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -5878,74 +6743,145 @@ sub apply_payments {
                  grep { $_->owed > 0 }
                  $self->cust_bill;
 
+  if ( $conf->exists('pkg-balances') ) {
+    # limit @payments to those w/ a pkgnum grepped from $self
+    my %pkgnums = ();
+    foreach my $i (@invoices) {
+      foreach my $li ( $i->cust_bill_pkg ) {
+        $pkgnums{$li->pkgnum} = 1;
+      }
+    }
+    @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+  }
+
   my $payment;
 
   foreach my $cust_bill ( @invoices ) {
-    my $amount;
 
     if ( !defined($payment) || $payment->unapplied == 0 ) {
       $payment = pop @payments or last;
     }
 
-    if ( $cust_bill->owed >= $payment->unapplied ) {
-      $amount = $payment->unapplied;
+    my $owed;
+    if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
+      $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
     } else {
-      $amount = $cust_bill->owed;
+      $owed = $cust_bill->owed;
+    }
+    unless ( $owed > 0 ) {
+      push @payments, $payment;
+      next;
     }
 
+    my $amount = min( $payment->unapplied, $owed );
+
     my $cust_bill_pay = new FS::cust_bill_pay ( {
       'paynum' => $payment->paynum,
       'invnum' => $cust_bill->invnum,
       'amount' => $amount,
     } );
-    my $error = $cust_bill_pay->insert;
+    $cust_bill_pay->pkgnum( $payment->pkgnum )
+      if $conf->exists('pkg-balances') && $payment->pkgnum;
+    my $error = $cust_bill_pay->insert(%options);
     if ( $error ) {
       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
       die $error;
     }
 
-    redo if ( $cust_bill->owed > 0);
+    redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
+
+  }
+
+  my $total_unapplied_payments = $self->total_unapplied_payments;
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return $total_unapplied_payments;
+}
+
+=item total_owed
+
+Returns the total owed for this customer on all invoices
+(see L<FS::cust_bill/owed>).
+
+=cut
+
+sub total_owed {
+  my $self = shift;
+  $self->total_owed_date(2145859200); #12/31/2037
+}
+
+=item total_owed_date TIME
+
+Returns the total owed for this customer on all invoices with date earlier than
+TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
+see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=cut
+
+sub total_owed_date {
+  my $self = shift;
+  my $time = shift;
+
+#  my $custnum = $self->custnum;
+#
+#  my $owed_sql = FS::cust_bill->owed_sql;
+#
+#  my $sql = "
+#    SELECT SUM($owed_sql) FROM cust_bill
+#      WHERE custnum = $custnum
+#        AND _date <= $time
+#  ";
+#
+#  my $sth = dbh->prepare($sql) or die dbh->errstr;
+#  $sth->execute() or die $sth->errstr;
+#
+#  return sprintf( '%.2f', $sth->fetchrow_arrayref->[0] );
 
+  my $total_bill = 0;
+  foreach my $cust_bill (
+    grep { $_->_date <= $time }
+      qsearch('cust_bill', { 'custnum' => $self->custnum, } )
+  ) {
+    $total_bill += $cust_bill->owed;
   }
+  sprintf( "%.2f", $total_bill );
 
-  my $total_unapplied_payments = $self->total_unapplied_payments;
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-  return $total_unapplied_payments;
 }
 
-=item total_owed
+=item total_owed_pkgnum PKGNUM
 
-Returns the total owed for this customer on all invoices
-(see L<FS::cust_bill/owed>).
+Returns the total owed on all invoices for this customer's specific package
+when using experimental package balances (see L<FS::cust_bill/owed_pkgnum>).
 
 =cut
 
-sub total_owed {
-  my $self = shift;
-  $self->total_owed_date(2145859200); #12/31/2037
+sub total_owed_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  $self->total_owed_date_pkgnum(2145859200, $pkgnum); #12/31/2037
 }
 
-=item total_owed_date TIME
+=item total_owed_date_pkgnum TIME PKGNUM
 
-Returns the total owed for this customer on all invoices with date earlier than
+Returns the total owed for this customer's specific package when using
+experimental package balances on all invoices with date earlier than
 TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
 see L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =cut
 
-sub total_owed_date {
-  my $self = shift;
-  my $time = shift;
+sub total_owed_date_pkgnum {
+  my( $self, $time, $pkgnum ) = @_;
+
   my $total_bill = 0;
   foreach my $cust_bill (
     grep { $_->_date <= $time }
       qsearch('cust_bill', { 'custnum' => $self->custnum, } )
   ) {
-    $total_bill += $cust_bill->owed;
+    $total_bill += $cust_bill->owed_pkgnum($pkgnum);
   }
   sprintf( "%.2f", $total_bill );
+
 }
 
 =item total_paid
@@ -5984,6 +6920,21 @@ sub total_unapplied_credits {
   sprintf( "%.2f", $total_credit );
 }
 
+=item total_unapplied_credits_pkgnum PKGNUM
+
+Returns the total outstanding credit (see L<FS::cust_credit>) for this
+customer.  See L<FS::cust_credit/credited>.
+
+=cut
+
+sub total_unapplied_credits_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  my $total_credit = 0;
+  $total_credit += $_->credited foreach $self->cust_credit_pkgnum($pkgnum);
+  sprintf( "%.2f", $total_credit );
+}
+
+
 =item total_unapplied_payments
 
 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
@@ -5998,6 +6949,22 @@ sub total_unapplied_payments {
   sprintf( "%.2f", $total_unapplied );
 }
 
+=item total_unapplied_payments_pkgnum PKGNUM
+
+Returns the total unapplied payments (see L<FS::cust_pay>) for this customer's
+specific package when using experimental package balances.  See
+L<FS::cust_pay/unapplied>.
+
+=cut
+
+sub total_unapplied_payments_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  my $total_unapplied = 0;
+  $total_unapplied += $_->unapplied foreach $self->cust_pay_pkgnum($pkgnum);
+  sprintf( "%.2f", $total_unapplied );
+}
+
+
 =item total_unapplied_refunds
 
 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
@@ -6050,6 +7017,56 @@ sub balance_date {
   );
 }
 
+=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Returns the balance for this customer, only considering invoices with date
+earlier than START_TIME, and optionally not later than END_TIME
+(total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
+
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
+
+Available options are:
+
+=over 4
+
+=item unapplied_date
+
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+
+=back
+
+=cut
+
+sub balance_date_range {
+  my $self = shift;
+  my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
+            ') FROM cust_main WHERE custnum='. $self->custnum;
+  sprintf( "%.2f", $self->scalar_sql($sql) );
+}
+
+=item balance_pkgnum PKGNUM
+
+Returns the balance for this customer's specific package when using
+experimental package balances (total_owed plus total_unrefunded, minus
+total_unapplied_credits minus total_unapplied_payments)
+
+=cut
+
+sub balance_pkgnum {
+  my( $self, $pkgnum ) = @_;
+
+  sprintf( "%.2f",
+      $self->total_owed_pkgnum($pkgnum)
+# n/a - refunds aren't part of pkg-balances since they don't apply to invoices
+#    + $self->total_unapplied_refunds_pkgnum($pkgnum)
+    - $self->total_unapplied_credits_pkgnum($pkgnum)
+    - $self->total_unapplied_payments_pkgnum($pkgnum)
+  );
+}
+
 =item in_transit_payments
 
 Returns the total of requests for payments for this customer pending in 
@@ -6171,6 +7188,28 @@ sub paydate_monthyear {
   }
 }
 
+=item tax_exemption TAXNAME
+
+=cut
+
+sub tax_exemption {
+  my( $self, $taxname ) = @_;
+
+  qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum,
+                                     'taxname' => $taxname,
+                                   },
+          );
+}
+
+=item cust_main_exemption
+
+=cut
+
+sub cust_main_exemption {
+  my $self = shift;
+  qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } );
+}
+
 =item invoicing_list [ ARRAYREF ]
 
 If an arguement is given, sets these email addresses as invoice recipients
@@ -6343,6 +7382,24 @@ sub invoicing_list_emailonly_scalar {
   join(', ', $self->invoicing_list_emailonly);
 }
 
+=item referral_custnum_cust_main
+
+Returns the customer who referred this customer (or the empty string, if
+this customer was not referred).
+
+Note the difference with referral_cust_main method: This method,
+referral_custnum_cust_main returns the single customer (if any) who referred
+this customer, while referral_cust_main returns an array of customers referred
+BY this customer.
+
+=cut
+
+sub referral_custnum_cust_main {
+  my $self = shift;
+  return '' unless $self->referral_custnum;
+  qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
+}
+
 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
 
 Returns an array of customers referred by this customer (referral_custnum set
@@ -6350,6 +7407,11 @@ to this custnum).  If DEPTH is given, recurses up to the given depth, returning
 customers referred by customers referred by this customer and so on, inclusive.
 The default behavior is DEPTH 1 (no recursion).
 
+Note the difference with referral_custnum_cust_main method: This method,
+referral_cust_main, returns an array of customers referred BY this customer,
+while referral_custnum_cust_main returns the single customer (if any) who
+referred this customer.
+
 =cut
 
 sub referral_cust_main {
@@ -6456,33 +7518,77 @@ sub credit {
 
 }
 
-=item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
+=item charge HASHREF || AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
 
 Creates a one-time charge for this customer.  If there is an error, returns
 the error, otherwise returns false.
 
+New-style, with a hashref of options:
+
+  my $error = $cust_main->charge(
+                                  {
+                                    'amount'     => 54.32,
+                                    'quantity'   => 1,
+                                    'start_date' => str2time('7/4/2009'),
+                                    'pkg'        => 'Description',
+                                    'comment'    => 'Comment',
+                                    'additional' => [], #extra invoice detail
+                                    'classnum'   => 1,  #pkg_class
+
+                                    'setuptax'   => '', # or 'Y' for tax exempt
+
+                                    #internal taxation
+                                    'taxclass'   => 'Tax class',
+
+                                    #vendor taxation
+                                    'taxproduct' => 2,  #part_pkg_taxproduct
+                                    'override'   => {}, #XXX describe
+
+                                    #will be filled in with the new object
+                                    'cust_pkg_ref' => \$cust_pkg,
+
+                                    #generate an invoice immediately
+                                    'bill_now' => 0,
+                                    'invoice_terms' => '', #with these terms
+                                  }
+                                );
+
+Old-style:
+
+  my $error = $cust_main->charge( 54.32, 'Description', 'Comment', 'Tax class' );
+
 =cut
 
 sub charge {
   my $self = shift;
-  my ( $amount, $quantity, $pkg, $comment, $classnum, $additional );
+  my ( $amount, $quantity, $start_date, $classnum );
+  my ( $pkg, $comment, $additional );
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
+  my $no_auto = '';
+  my $cust_pkg_ref = '';
+  my ( $bill_now, $invoice_terms ) = ( 0, '' );
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
+    $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
+    $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
-    $additional = $_[0]->{additional};
+    $additional = $_[0]->{additional} || [];
     $taxproduct = $_[0]->{taxproductnum};
     $override   = { '' => $_[0]->{tax_override} };
-  }else{
+    $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
+    $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
+    $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
+  } else {
     $amount     = shift;
     $quantity   = 1;
+    $start_date = '';
     $pkg        = @_ ? shift : 'One-time charge';
     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
     $setuptax   = '';
@@ -6507,7 +7613,7 @@ sub charge {
     'plan'          => 'flat',
     'freq'          => 0,
     'disabled'      => 'Y',
-    'classnum'      => $classnum ? $classnum : '',
+    'classnum'      => ( $classnum ? $classnum : '' ),
     'setuptax'      => $setuptax,
     'taxclass'      => $taxclass,
     'taxproductnum' => $taxproduct,
@@ -6540,19 +7646,33 @@ sub charge {
   }
 
   my $cust_pkg = new FS::cust_pkg ( {
-    'custnum'  => $self->custnum,
-    'pkgpart'  => $pkgpart,
-    'quantity' => $quantity,
+    'custnum'    => $self->custnum,
+    'pkgpart'    => $pkgpart,
+    'quantity'   => $quantity,
+    'start_date' => $start_date,
+    'no_auto'    => $no_auto,
   } );
 
   $error = $cust_pkg->insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
+  } elsif ( $cust_pkg_ref ) {
+    ${$cust_pkg_ref} = $cust_pkg;
+  }
+
+  if ( $bill_now ) {
+    my $error = $self->bill( 'invoice_terms' => $invoice_terms,
+                             'pkg_list'      => [ $cust_pkg ],
+                           );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }   
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+  return '';
 
 }
 
@@ -6591,6 +7711,7 @@ Returns all the invoices (see L<FS::cust_bill>) for this customer.
 
 sub cust_bill {
   my $self = shift;
+  map { $_ } #return $self->num_cust_bill unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch('cust_bill', { 'custnum' => $self->custnum, } )
 }
@@ -6604,7 +7725,27 @@ customer.
 
 sub open_cust_bill {
   my $self = shift;
-  grep { $_->owed > 0 } $self->cust_bill;
+
+  qsearch({
+    'table'     => 'cust_bill',
+    'hashref'   => { 'custnum' => $self->custnum, },
+    'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
+    'order_by'  => 'ORDER BY _date ASC',
+  });
+
+}
+
+=item cust_statements
+
+Returns all the statements (see L<FS::cust_statement>) for this customer.
+
+=cut
+
+sub cust_statement {
+  my $self = shift;
+  map { $_ } #return $self->num_cust_statement unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch('cust_statement', { 'custnum' => $self->custnum, } )
 }
 
 =item cust_credit
@@ -6615,10 +7756,28 @@ Returns all the credits (see L<FS::cust_credit>) for this customer.
 
 sub cust_credit {
   my $self = shift;
+  map { $_ } #return $self->num_cust_credit unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
 }
 
+=item cust_credit_pkgnum
+
+Returns all the credits (see L<FS::cust_credit>) for this customer's specific
+package when using experimental package balances.
+
+=cut
+
+sub cust_credit_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_credit_pkgnum($pkgnum) unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit', { 'custnum' => $self->custnum,
+                              'pkgnum'  => $pkgnum,
+                            }
+    );
+}
+
 =item cust_pay
 
 Returns all the payments (see L<FS::cust_pay>) for this customer.
@@ -6627,10 +7786,43 @@ Returns all the payments (see L<FS::cust_pay>) for this customer.
 
 sub cust_pay {
   my $self = shift;
+  return $self->num_cust_pay unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
 }
 
+=item num_cust_pay
+
+Returns the number of payments (see L<FS::cust_pay>) for this customer.  Also
+called automatically when the cust_pay method is used in a scalar context.
+
+=cut
+
+sub num_cust_pay {
+  my $self = shift;
+  my $sql = "SELECT COUNT(*) FROM cust_pay WHERE custnum = ?";
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute($self->custnum) or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
+
+=item cust_pay_pkgnum
+
+Returns all the payments (see L<FS::cust_pay>) for this customer's specific
+package when using experimental package balances.
+
+=cut
+
+sub cust_pay_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_pay_pkgnum($pkgnum) unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay', { 'custnum' => $self->custnum,
+                           'pkgnum'  => $pkgnum,
+                         }
+    );
+}
+
 =item cust_pay_void
 
 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
@@ -6639,6 +7831,7 @@ Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
 
 sub cust_pay_void {
   my $self = shift;
+  map { $_ } #return $self->num_cust_pay_void unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
 }
@@ -6651,7 +7844,8 @@ Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
 
 sub cust_pay_batch {
   my $self = shift;
-  sort { $a->_date <=> $b->_date }
+  map { $_ } #return $self->num_cust_pay_batch unless wantarray;
+  sort { $a->paybatchnum <=> $b->paybatchnum }
     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
 }
 
@@ -6698,6 +7892,7 @@ Returns all the refunds (see L<FS::cust_refund>) for this customer.
 
 sub cust_refund {
   my $self = shift;
+  map { $_ } #return $self->num_cust_refund unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
 }
@@ -6855,9 +8050,11 @@ sub geocode {
                ? 'ship_'
                : '';
 
-  my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
+  my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
     if $self->country eq 'US';
 
+  $zip ||= '';
+  $plus4 ||= '';
   #CCH specific location stuff
   my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
 
@@ -6996,6 +8193,19 @@ sub support_services {
 
 }
 
+# Return a list of latitude/longitude for one of the services (if any)
+sub service_coordinates {
+  my $self = shift;
+
+  my @svc_X = 
+    grep { $_->latitude && $_->longitude }
+    map { $_->svc_x }
+    map { $_->cust_svc }
+    $self->ncancelled_pkgs;
+
+  scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -7197,6 +8407,32 @@ sub balance_date_sql {
 
 }
 
+=item unapplied_payments_date_sql START_TIME [ END_TIME ]
+
+Returns an SQL fragment to retreive the total unapplied payments for this
+customer, only considering invoices with date earlier than START_TIME, and
+optionally not later than END_TIME.
+
+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:
+
+=cut
+
+sub unapplied_payments_date_sql {
+  my( $class, $start, $end, ) = @_;
+
+  my $unapp_pay    = FS::cust_pay->unapplied_sql;
+
+  my $pay_where = $class->_money_table_where( 'cust_pay', $start, $end,
+                                                          'unapplied_date'=>1 );
+
+  " ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $pay_where ) ";
+}
+
 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
 
 Helper method for balance_date_sql; name (and usage) subject to change
@@ -7227,12 +8463,12 @@ sub _money_table_where {
 
 }
 
-=item search_sql HASHREF
+=item search HASHREF
 
 (Class method)
 
-Returns a qsearch hash expression to search for parameters specified in HREF.
-Valid parameters are
+Returns a qsearch hash expression to search for parameters specified in
+HASHREF.  Valid parameters are
 
 =over 4
 
@@ -7252,6 +8488,10 @@ listref of start date, end date
 
 listref
 
+=item paydate_year
+
+=item paydate_month
+
 =item current_balance
 
 listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
@@ -7266,7 +8506,7 @@ bool
 
 =cut
 
-sub search_sql {
+sub search {
   my ($class, $params) = @_;
 
   my $dbh = dbh;
@@ -7304,6 +8544,13 @@ sub search_sql {
     unless $params->{'cancelled_pkgs'};
 
   ##
+  # parse without census tract checkbox
+  ##
+
+  push @where, "(censustract = '' or censustract is null)"
+    if $params->{'no_censustract'};
+
+  ##
   # dates
   ##
 
@@ -7323,23 +8570,97 @@ sub search_sql {
   }
 
   ###
+  # classnum
+  ###
+
+  if ( $params->{'classnum'} ) {
+
+    my @classnum = ref( $params->{'classnum'} )
+                     ? @{ $params->{'classnum'} }
+                     :  ( $params->{'classnum'} );
+
+    @classnum = grep /^(\d*)$/, @classnum;
+
+    if ( @classnum ) {
+      push @where, '( '. join(' OR ', map {
+                                            $_ ? "cust_main.classnum = $_"
+                                               : "cust_main.classnum IS NULL"
+                                          }
+                                          @classnum
+                             ).
+                   ' )';
+    }
+
+  }
+
+  ###
   # payby
   ###
 
-  my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
-  if ( @payby ) {
-    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
+  if ( $params->{'payby'} ) {
+
+    my @payby = ref( $params->{'payby'} )
+                  ? @{ $params->{'payby'} }
+                  :  ( $params->{'payby'} );
+
+    @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
+
+    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
+      if @payby;
+
+  }
+
+  ###
+  # paydate_year / paydate_month
+  ###
+
+  if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
+    my $year = $1;
+    $params->{'paydate_month'} =~ /^(\d\d?)$/
+      or die "paydate_year without paydate_month?";
+    my $month = $1;
+
+    push @where,
+      'paydate IS NOT NULL',
+      "paydate != ''",
+      "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
+;
+  }
+
+  ###
+  # invoice terms
+  ###
+
+  if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
+    my $terms = $1;
+    if ( $1 eq 'NULL' ) {
+      push @where,
+        "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
+    } else {
+      push @where,
+        "cust_main.invoice_terms IS NOT NULL",
+        "cust_main.invoice_terms = '$1'";
+    }
   }
 
   ##
   # amounts
   ##
 
-  #my $balance_sql = $class->balance_sql();
-  my $balance_sql = FS::cust_main->balance_sql();
+  if ( $params->{'current_balance'} ) {
+
+    #my $balance_sql = $class->balance_sql();
+    my $balance_sql = FS::cust_main->balance_sql();
+
+    my @current_balance =
+      ref( $params->{'current_balance'} )
+      ? @{ $params->{'current_balance'} }
+      :  ( $params->{'current_balance'} );
 
-  push @where, map { s/current_balance/$balance_sql/; $_ }
-                   @{ $params->{'current_balance'} };
+    push @where, map { s/current_balance/$balance_sql/; $_ }
+                     @current_balance;
+
+  }
 
   ##
   # custbatch
@@ -7417,13 +8738,13 @@ sub search_sql {
 
 }
 
-=item email_search_sql HASHREF
+=item email_search_result HASHREF
 
 (Class method)
 
 Emails a notice to the specified customers.
 
-Valid parameters are those of the L<search_sql> method, plus the following:
+Valid parameters are those of the L<search> method, plus the following:
 
 =over 4
 
@@ -7457,7 +8778,7 @@ retrying everything.
 
 =cut
 
-sub email_search_sql {
+sub email_search_result {
   my($class, $params) = @_;
 
   my $from = delete $params->{from};
@@ -7467,7 +8788,10 @@ sub email_search_sql {
 
   my $job = delete $params->{'job'};
 
-  my $sql_query = $class->search_sql($params);
+  $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
+    unless ref($params->{'payby'});
+
+  my $sql_query = $class->search($params);
 
   my $count_query   = delete($sql_query->{'count_query'});
   my $count_sth = dbh->prepare($count_query)
@@ -7519,7 +8843,7 @@ sub email_search_sql {
 use Storable qw(thaw);
 use Data::Dumper;
 use MIME::Base64;
-sub process_email_search_sql {
+sub process_email_search_result {
   my $job = shift;
   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
 
@@ -7528,7 +8852,10 @@ sub process_email_search_sql {
 
   $param->{'job'} = $job;
 
-  my $error = FS::cust_main->email_search_sql( $param );
+  $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
+    unless ref($param->{'payby'});
+
+  my $error = FS::cust_main->email_search_result( $param );
   die $error if $error;
 
 }
@@ -7536,8 +8863,8 @@ sub process_email_search_sql {
 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
-records.  Currently, I<first>, I<last> and/or I<company> may be specified (the
-appropriate ship_ field is also searched).
+records.  Currently, I<first>, I<last>, I<company> and/or I<address1> may be
+specified (the appropriate ship_ field is also searched).
 
 Additional options are the same as FS::Record::qsearch
 
@@ -7666,15 +8993,18 @@ sub smart_search {
   } 
 
   if ( $search =~ /^\s*(\d+)\s*$/
-            || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
-                 && $search =~ /^\s*(\w\w?\d+)\s*$/
-               )
-          )
+         || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
+              && $search =~ /^\s*(\w\w?\d+)\s*$/
+            )
+         || ( $conf->exists('address1-search' )
+              && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
+            )
+     )
   {
 
     my $num = $1;
 
-    if ( $num <= 2147483647 ) { #need a bigint custnum?  wow.
+    if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { 'custnum' => $num, %options },
@@ -7688,23 +9018,42 @@ sub smart_search {
       'extra_sql' => " AND $agentnums_sql", #agent virtualization
     } );
 
+    if ( $conf->exists('address1-search') ) {
+      my $len = length($num);
+      $num = lc($num);
+      foreach my $prefix ( '', 'ship_' ) {
+        push @cust_main, qsearch( {
+          'table'     => 'cust_main',
+          'hashref'   => { %options, },
+          'extra_sql' => 
+            ( keys(%options) ? ' AND ' : ' WHERE ' ).
+            " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
+            " AND $agentnums_sql",
+        } );
+      }
+    }
+
   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
 
     my($company, $last, $first) = ( $1, $2, $3 );
 
     # "Company (Last, First)"
     #this is probably something a browser remembered,
-    #so just do an exact search
+    #so just do an exact search (but case-insensitive, so USPS standardization
+    #doesn't throw a wrench in the works)
 
     foreach my $prefix ( '', 'ship_' ) {
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
-        'hashref'   => { $prefix.'first'   => $first,
-                         $prefix.'last'    => $last,
-                         $prefix.'company' => $company,
-                         %options,
-                       },
-        'extra_sql' => " AND $agentnums_sql",
+        'hashref'   => { %options },
+        'extra_sql' => 
+          ( keys(%options) ? ' AND ' : ' WHERE ' ).
+          join(' AND ',
+            " LOWER(${prefix}first)   = ". dbh->quote(lc($first)),
+            " LOWER(${prefix}last)    = ". dbh->quote(lc($last)),
+            " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
+            $agentnums_sql,
+          ),
       } );
     }
 
@@ -7763,11 +9112,16 @@ sub smart_search {
 
     #exact
     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-    $sql .= " (    LOWER(last)         = $q_value
-                OR LOWER(company)      = $q_value
-                OR LOWER(ship_last)    = $q_value
-                OR LOWER(ship_company) = $q_value
-              )";
+    $sql .= " (    LOWER(last)          = $q_value
+                OR LOWER(company)       = $q_value
+                OR LOWER(ship_last)     = $q_value
+                OR LOWER(ship_company)  = $q_value
+            ";
+    $sql .= "   OR LOWER(address1)      = $q_value
+                OR LOWER(ship_address1) = $q_value
+            "
+      if $conf->exists('address1-search');
+    $sql .= " )";
 
     push @cust_main, qsearch( {
       'table'     => 'cust_main',
@@ -7780,7 +9134,7 @@ sub smart_search {
     #getting complaints searches are not returning enough
     unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
 
-      #still some false laziness w/search_sql (was search/cust_main.cgi)
+      #still some false laziness w/search (was search/cust_main.cgi)
 
       #substring
 
@@ -7808,6 +9162,13 @@ sub smart_search {
         ;
       }
 
+      if ( $conf->exists('address1-search') ) {
+        push @hashrefs,
+          { 'address1'      => { op=>'ILIKE', value=>"%$value%" }, },
+          { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
+        ;
+      }
+
       foreach my $hashref ( @hashrefs ) {
 
         push @cust_main, qsearch( {
@@ -7838,15 +9199,19 @@ sub smart_search {
         push @cust_main,
           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
       }
+      if ( $conf->exists('address1-search') ) {
+        push @cust_main,
+          FS::cust_main->fuzzy_search( { 'address1' => $value }, @fuzopts );
+      }
 
     }
 
-    #eliminate duplicates
-    my %saw = ();
-    @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
-
   }
 
+  #eliminate duplicates
+  my %saw = ();
+  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
   @cust_main;
 
 }
@@ -7921,9 +9286,6 @@ sub email_search {
 
 =cut
 
-use vars qw(@fuzzyfields);
-@fuzzyfields = ( 'last', 'first', 'company' );
-
 sub check_and_rebuild_fuzzyfiles {
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
   rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
@@ -7983,7 +9345,7 @@ sub all_X {
   \@array;
 }
 
-=item append_fuzzyfiles LASTNAME COMPANY
+=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
 
 =cut
 
@@ -7996,7 +9358,7 @@ sub append_fuzzyfiles {
 
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
 
-  foreach my $field (qw( first last company )) {
+  foreach my $field (@fuzzyfields) {
     my $value = shift;
 
     if ( $value ) {
@@ -8414,6 +9776,15 @@ sub queued_bill {
       );
 }
 
+sub _upgrade_data { #class method
+  my ($class, %opts) = @_;
+
+  my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL';
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+
+}
+
 =back
 
 =head1 BUGS