correct invoice package address display and reduce false laziness
[freeside.git] / FS / FS / cust_main.pm
index c83acc6..17dda9c 100644 (file)
@@ -2,8 +2,12 @@ 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;
@@ -41,6 +45,7 @@ use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_location;
+use FS::cust_class;
 use FS::cust_main_exemption;
 use FS::cust_tax_adjustment;
 use FS::tax_rate;
@@ -77,9 +82,11 @@ $DEBUG = 0;
 $me = '[FS::cust_main]';
 
 $import = 0;
-$skip_fuzzyfiles = 0;
 $ignore_expired_card = 0;
 
+$skip_fuzzyfiles = 0;
+@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
+
 @encrypted_fields = ('payinfo', 'paycvv');
 sub nohistory_fields { ('paycvv'); }
 
@@ -1492,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";
@@ -1502,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";
@@ -1535,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')
@@ -1951,6 +1955,61 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+
+Options are
+
+=over 4
+
+=item join_string
+
+used to separate the address elements (defaults to ', ')
+
+=item escape_function
+
+a callback used for escaping the text of the address elements
+
+=back
+
+=cut
+
+# false laziness with FS::cust_location::line
+
+sub location_label {
+  my $self = shift;
+  my %opt = @_;
+
+  my $separator = $opt{join_string} || ', ';
+  my $escape = $opt{escape_function} || sub{ shift };
+  my $line = '';
+  my $cydefault = FS::conf->new->config('countrydefault') || 'US';
+  my $prefix = length($self->ship_last) ? 'ship_' : '';
+
+  my $notfirst = 0;
+  foreach (qw ( address1 address2 ) ) {
+    my $method = "$prefix$_";
+    $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
+      if $self->$method;
+    $notfirst++;
+  }
+  $notfirst = 0;
+  foreach (qw ( city county state zip ) ) {
+    my $method = "$prefix$_";
+    if ( $self->$method ) {
+      $line .= '(' if $method eq 'county';
+      $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
+      $line .= ')' if $method eq 'county';
+      $notfirst++;
+    }
+  }
+  $line .= $separator. &$escape(code2country($self->country))
+    if $self->country ne $cydefault;
+
+  $line;
+}
+
 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -2012,6 +2071,9 @@ sub _cust_pkg {
 # This should be generalized to use config options to determine order.
 sub sort_packages {
   
+  my $locationsort = $a->locationnum <=> $b->locationnum;
+  return $locationsort if $locationsort;
+
   if ( $a->get('cancel') xor $b->get('cancel') ) {
     return -1 if $b->get('cancel');
     return  1 if $a->get('cancel');
@@ -2303,12 +2365,61 @@ sub agent {
   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
 }
 
+=item cust_class
+
+Returns the customer class, as an FS::cust_class object, or the empty string
+if there is no customer class.
+
+=cut
+
+sub cust_class {
+  my $self = shift;
+  if ( $self->classnum ) {
+    qsearchs('cust_class', { 'classnum' => $self->classnum } );
+  } else {
+    return '';
+  } 
+}
+
+=item categoryname 
+
+Returns the customer category name, or the empty string if there is no customer
+category.
+
+=cut
+
+sub categoryname {
+  my $self = shift;
+  my $cust_class = $self->cust_class;
+  $cust_class
+    ? $cust_class->categoryname
+    : '';
+}
+
+=item classname 
+
+Returns the customer class name, or the empty string if there is no customer
+class.
+
+=cut
+
+sub classname {
+  my $self = shift;
+  my $cust_class = $self->cust_class;
+  $cust_class
+    ? $cust_class->classname
+    : '';
+}
+
+
 =item bill_and_collect 
 
 Cancels and suspends any packages due, generates bills, applies payments and
-cred
+credits, and applies collection events to run cards, send bills and notices,
+etc.
 
-Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
+By default, warns on errors and continues with the next operation (but see the
+"fatal" flag below).
 
 Options are passed as name-value pairs.  Currently available options are:
 
@@ -2334,6 +2445,12 @@ Used in conjunction with the I<time> option, this option specifies the date of f
 
 If set true, re-charges setup fees.
 
+=item fatal
+
+If set any errors prevent subsequent operations from continusing.  If set
+specifically to "return", returns the error (or false, if there is no error).
+Any other true value causes errors to die.
+
 =item debug
 
 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
@@ -2348,34 +2465,70 @@ options of those methods are also available.
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
+  my $error;
+
   #$options{actual_time} not $options{time} because freeside-daily -d is for
   #pre-printing invoices
-  $self->cancel_expired_pkgs(    $options{actual_time} );
-  $self->suspend_adjourned_pkgs( $options{actual_time} );
 
-  my $error = $self->bill( %options );
-  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
+  $options{'actual_time'} ||= time;
 
-  $self->apply_payments_and_credits;
+  $error = $self->cancel_expired_pkgs( $options{actual_time} );
+  if ( $error ) {
+    $error = "Error expiring custnum ". $self->custnum. ": $error";
+    if    ( $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'} 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'} 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'} eq 'return' ) { return $error; }
+    elsif ( $options{'fatal'}             ) { die    $error; }
+    else                                    { warn   $error; }
+  }
 
   unless ( $conf->exists('cancelled_cust-noevents')
            && ! $self->num_ncancelled_pkgs
   ) {
-
     $error = $self->collect( %options );
-    warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
-
+    if ( $error ) {
+      $error = "Error collecting custnum ". $self->custnum. ": $error";
+      if    ( $options{'fatal'} eq 'return' ) { return $error; }
+      elsif ( $options{'fatal'}             ) { die    $error; }
+      else                                    { warn   $error; }
+    }
   }
 
+  '';
+
 }
 
 sub cancel_expired_pkgs {
-  my ( $self, $time ) = @_;
+  my ( $self, $time, %options ) = @_;
 
   my @cancel_pkgs = $self->ncancelled_pkgs( { 
     'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
   } );
 
+  my @errors = ();
+
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
     my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
@@ -2383,15 +2536,15 @@ sub cancel_expired_pkgs {
                                          )
                                        : ()
                                  );
-    warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
+  scalar(@errors) ? join(' / ', @errors) : '';
+
 }
 
 sub suspend_adjourned_pkgs {
-  my ( $self, $time ) = @_;
+  my ( $self, $time, %options ) = @_;
 
   my @susp_pkgs = $self->ncancelled_pkgs( {
     'extra_sql' =>
@@ -2415,6 +2568,8 @@ sub suspend_adjourned_pkgs {
          }
          @susp_pkgs;
 
+  my @errors = ();
+
   foreach my $cust_pkg ( @susp_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
       if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
@@ -2423,12 +2578,11 @@ sub suspend_adjourned_pkgs {
                                           )
                                         : ()
                                   );
-
-    warn "Error suspending package ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
+  scalar(@errors) ? join(' / ', @errors) : '';
+
 }
 
 =item bill OPTIONS
@@ -2477,7 +2631,7 @@ plans support this feature (they tend to charge 0).
 
 =item invoice_terms
 
-Options terms to be printed on this invocice.  Otherwise, customer-specific
+Optional terms to be printed on this invoice.  Otherwise, customer-specific
 terms or the default terms are used.
 
 =back
@@ -2621,6 +2775,141 @@ sub bill {
 
   }
 
+  my $listref_or_error =
+    $self->calculate_taxes( \@cust_bill_pkg, \%taxlisthash, $invoice_time);
+
+  unless ( ref( $listref_or_error ) ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $listref_or_error;
+  }
+
+  foreach my $taxline ( @$listref_or_error ) {
+    $total_setup = sprintf('%.2f', $total_setup+$taxline->setup );
+    push @cust_bill_pkg, $taxline;
+  }
+
+  #add tax adjustments
+  warn "adding tax adjustments...\n" if $DEBUG > 2;
+  foreach my $cust_tax_adjustment (
+    qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
+                                     'billpkgnum' => '',
+                                   }
+           )
+  ) {
+
+    my $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 + $total_recur );
+
+  my @cust_bill = $self->cust_bill;
+  my $balance = $self->balance;
+  my $previous_balance = scalar(@cust_bill)
+                           ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
+                           : 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 $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;
 
   # keys are tax names (as printed on invoices / itemdesc )
@@ -2639,20 +2928,18 @@ sub bill {
   # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
   my %tax_rate_location = ();
 
-  foreach my $tax ( keys %taxlisthash ) {
-    my $tax_object = shift @{ $taxlisthash{$tax} };
+  foreach my $tax ( keys %$taxlisthash ) {
+    my $tax_object = shift @{ $taxlisthash->{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
-    warn " ". join('/', @{ $taxlisthash{$tax} } ). "\n" if $DEBUG > 2;
+    warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
     my $hashref_or_error =
-      $tax_object->taxline( $taxlisthash{$tax},
+      $tax_object->taxline( $taxlisthash->{$tax},
                             'custnum'      => $self->custnum,
                             'invoice_time' => $invoice_time
                           );
-    unless ( ref($hashref_or_error) ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $hashref_or_error;
-    }
-    unshift @{ $taxlisthash{$tax} }, $tax_object;
+    return $hashref_or_error unless ref($hashref_or_error);
+
+    unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
     my $name   = $hashref_or_error->{'name'};
     my $amount = $hashref_or_error->{'amount'};
@@ -2692,9 +2979,9 @@ sub bill {
   }
 
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
-  my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
-  foreach my $tax ( keys %taxlisthash ) {
-    foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
+  my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
+  foreach my $tax ( keys %$taxlisthash ) {
+    foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
       next unless ref($_) eq 'FS::cust_bill_pkg';
 
       push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
@@ -2724,7 +3011,6 @@ sub bill {
     next unless $tax;
 
     $tax = sprintf('%.2f', $tax );
-    $total_setup = sprintf('%.2f', $total_setup+$tax );
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
@@ -2743,7 +3029,7 @@ sub bill {
 
     }
 
-    push @cust_bill_pkg, new FS::cust_bill_pkg {
+    push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
       'setup'    => $tax,
       'recur'    => 0,
@@ -2757,88 +3043,9 @@ sub bill {
 
   }
 
-  #add tax adjustments
-  warn "adding tax adjustments...\n" if $DEBUG > 2;
-  foreach my $cust_tax_adjustment (
-    qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
-                                     'billpkgnum' => '',
-                                   }
-           )
-  ) {
-
-    my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
-    $total_setup = sprintf('%.2f', $total_setup+$tax );
-
-    my $itemdesc = $cust_tax_adjustment->taxname;
-    $itemdesc = '' if $itemdesc eq 'Tax';
-
-    push @cust_bill_pkg, new FS::cust_bill_pkg {
-      'pkgnum'      => 0,
-      'setup'       => $tax,
-      'recur'       => 0,
-      'sdate'       => '',
-      'edate'       => '',
-      'itemdesc'    => $itemdesc,
-      'itemcomment' => $cust_tax_adjustment->comment,
-      'cust_tax_adjustment' => $cust_tax_adjustment,
-      #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
-    };
-
-  }
-
-  my $charged = sprintf('%.2f', $total_setup + $total_recur );
-
-  my @cust_bill = $self->cust_bill;
-  my $balance = $self->balance;
-  my $previous_balance = scalar(@cust_bill)
-                           ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
-                           : 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 $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) = @_;
 
@@ -3105,7 +3312,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;
@@ -3120,15 +3327,24 @@ sub _handle_taxes {
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      my @taxes = qsearch( 'cust_main_county', \%taxhash );
-
+      my @taxes = ();
       my %taxhash_elim = %taxhash;
+      my @elim = qw( city county state );
+      do { 
 
-      my @elim = qw( taxclass county state );
-      while ( !scalar(@taxes) && scalar(@elim) ) {
-        $taxhash_elim{ shift(@elim) } = '';
+        #first try a match with taxclass
         @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
-      }
+
+        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+          #then try a match without taxclass
+          my %no_taxclass = %taxhash_elim;
+          $no_taxclass{ 'taxclass' } = '';
+          @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+        }
+
+        $taxhash_elim{ shift(@elim) } = '';
+
+      } while ( !scalar(@taxes) && scalar(@elim) );
 
       @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
                     @taxes
@@ -3850,11 +4066,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.
 
@@ -3873,13 +4092,32 @@ 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';
+  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'};
 
@@ -4347,6 +4585,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
 
     }
@@ -4436,9 +4684,9 @@ sub realtime_bop {
 sub _bop_recurring_billing {
   my( $self, %opt ) = @_;
 
-  my $method = $conf->config('credit_card-recurring_billing_flag');
+  my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
 
-  if ( $method eq 'transaction_is_recur' ) {
+  if ( defined($method) && $method eq 'transaction_is_recur' ) {
 
     return 1 if $opt{'trans_is_recur'};
 
@@ -4629,6 +4877,9 @@ sub realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    $content{'card_number'} = $cust_pay->payinfo
+      if $cust_pay->payby eq 'CARD'
+      && $void->can('info') && $void->info('CC_void_requires_card');
     $void->content( 'action' => 'void', %content );
     $void->submit();
     if ( $void->is_success ) {
@@ -4802,7 +5053,6 @@ sub _new_bop_required {
   '';
 }
   
-
 =item realtime_collect [ OPTION => VALUE ... ]
 
 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
@@ -4826,11 +5076,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.
 
@@ -4878,7 +5131,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
@@ -4929,7 +5183,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} );
@@ -5513,6 +5778,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
 
     }
@@ -5943,6 +6218,9 @@ sub _new_realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    $content{'card_number'} = $cust_pay->payinfo
+      if $cust_pay->payby eq 'CARD'
+      && $void->can('info') && $void->info('CC_void_requires_card');
     $void->content( 'action' => 'void', %content );
     $void->submit();
     if ( $void->is_success ) {
@@ -6374,7 +6652,6 @@ A hash of optional arguments may be passed.  Currently "manual" is supported.
 If true, a payment receipt is sent instead of a statement when
 'payment_receipt_email' configuration option is set.
 
-
 Dies if there is an error.
 
 =cut
@@ -6679,6 +6956,36 @@ sub balance_date {
   );
 }
 
+=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Returns the balance for this customer, only considering invoices with date
+earlier than START_TIME, and optionally not later than END_TIME
+(total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
+
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
+
+Available options are:
+
+=over 4
+
+=item unapplied_date
+
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+
+=back
+
+=cut
+
+sub balance_date_range {
+  my $self = shift;
+  my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
+            ') FROM cust_main WHERE custnum='. $self->custnum;
+  sprintf( "%.2f", $self->scalar_sql($sql) );
+}
+
 =item balance_pkgnum PKGNUM
 
 Returns the balance for this customer's specific package when using
@@ -7243,7 +7550,7 @@ sub charge {
     'plan'          => 'flat',
     'freq'          => 0,
     'disabled'      => 'Y',
-    'classnum'      => $classnum ? $classnum : '',
+    'classnum'      => ( $classnum ? $classnum : '' ),
     'setuptax'      => $setuptax,
     'taxclass'      => $taxclass,
     'taxproductnum' => $taxproduct,
@@ -8090,7 +8397,7 @@ sub _money_table_where {
 
 }
 
-=item search_sql HASHREF
+=item search HASHREF
 
 (Class method)
 
@@ -8115,6 +8422,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'))
@@ -8129,7 +8440,7 @@ bool
 
 =cut
 
-sub search_sql {
+sub search {
   my ($class, $params) = @_;
 
   my $dbh = dbh;
@@ -8193,23 +8504,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
@@ -8287,13 +8672,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
 
@@ -8327,7 +8712,7 @@ retrying everything.
 
 =cut
 
-sub email_search_sql {
+sub email_search_result {
   my($class, $params) = @_;
 
   my $from = delete $params->{from};
@@ -8340,7 +8725,7 @@ sub email_search_sql {
   $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
     unless ref($params->{'payby'});
 
-  my $sql_query = $class->search_sql($params);
+  my $sql_query = $class->search($params);
 
   my $count_query   = delete($sql_query->{'count_query'});
   my $count_sth = dbh->prepare($count_query)
@@ -8392,7 +8777,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;
 
@@ -8404,7 +8789,7 @@ sub process_email_search_sql {
   $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
     unless ref($param->{'payby'});
 
-  my $error = FS::cust_main->email_search_sql( $param );
+  my $error = FS::cust_main->email_search_result( $param );
   die $error if $error;
 
 }
@@ -8412,8 +8797,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
 
@@ -8542,15 +8927,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 },
@@ -8564,23 +8952,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,
+          ),
       } );
     }
 
@@ -8639,11 +9046,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',
@@ -8656,7 +9068,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
 
@@ -8684,6 +9096,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( {
@@ -8714,6 +9133,10 @@ sub smart_search {
         push @cust_main,
           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
       }
+      if ( $conf->exists('address1-search') ) {
+        push @cust_main,
+          FS::cust_main->fuzzy_search( { 'address1' => $value }, @fuzopts );
+      }
 
     }
 
@@ -8797,9 +9220,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
@@ -8859,7 +9279,7 @@ sub all_X {
   \@array;
 }
 
-=item append_fuzzyfiles LASTNAME COMPANY
+=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
 
 =cut
 
@@ -8872,7 +9292,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 ) {